mirror of
https://github.com/morgan9e/macos-stats
synced 2026-04-15 00:34:08 +09:00
feat: replaced login view for Remote with web authorization mechanism
This commit is contained in:
@@ -45,6 +45,10 @@ identifier_name:
|
|||||||
- SensorsWidgetValue
|
- SensorsWidgetValue
|
||||||
- access_token
|
- access_token
|
||||||
- refresh_token
|
- refresh_token
|
||||||
|
- device_code
|
||||||
|
- user_code
|
||||||
|
- verification_uri_complete
|
||||||
|
- expires_in
|
||||||
|
|
||||||
line_length: 200
|
line_length: 200
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Cocoa
|
|||||||
|
|
||||||
public class Remote {
|
public class Remote {
|
||||||
public static let shared = 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
|
static public var host = URL(string: "https://api.system-stats.com")! // https://api.system-stats.com http://localhost:8008
|
||||||
|
|
||||||
public var state: Bool {
|
public var state: Bool {
|
||||||
get { Store.shared.bool(key: "remote_state", defaultValue: false) }
|
get { Store.shared.bool(key: "remote_state", defaultValue: false) }
|
||||||
@@ -54,6 +54,13 @@ public class Remote {
|
|||||||
NotificationCenter.default.removeObserver(self, name: .remoteLoginSuccess, object: nil)
|
NotificationCenter.default.removeObserver(self, name: .remoteLoginSuccess, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func login() {
|
||||||
|
self.auth.login { url in
|
||||||
|
guard let url else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func logout() {
|
public func logout() {
|
||||||
self.auth.logout()
|
self.auth.logout()
|
||||||
self.isAuthorized = false
|
self.isAuthorized = false
|
||||||
@@ -105,6 +112,12 @@ public class RemoteAuth {
|
|||||||
get { Store.shared.string(key: "refresh_token", defaultValue: "") }
|
get { Store.shared.string(key: "refresh_token", defaultValue: "") }
|
||||||
set { Store.shared.set(key: "refresh_token", value: newValue) }
|
set { Store.shared.set(key: "refresh_token", value: newValue) }
|
||||||
}
|
}
|
||||||
|
private var clientID: String = "stats"
|
||||||
|
|
||||||
|
private var deviceCode: String = ""
|
||||||
|
private var userCode: String = ""
|
||||||
|
private var interval: Int = 5
|
||||||
|
private var repeater: Repeater?
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(self.successLogin), name: .remoteLoginSuccess, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(self.successLogin), name: .remoteLoginSuccess, object: nil)
|
||||||
@@ -118,6 +131,36 @@ public class RemoteAuth {
|
|||||||
self.validate(completion)
|
self.validate(completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func login(completion: @escaping (URL?) -> Void) {
|
||||||
|
self.registerDevice { device in
|
||||||
|
guard let device else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(device.verification_uri_complete)
|
||||||
|
|
||||||
|
self.deviceCode = device.device_code
|
||||||
|
self.userCode = device.user_code
|
||||||
|
self.interval = device.interval ?? 5
|
||||||
|
|
||||||
|
self.repeater = Repeater(seconds: self.interval) {
|
||||||
|
self.pollForToken { error in
|
||||||
|
guard error == nil else {
|
||||||
|
print(error?.localizedDescription ?? "error pooling for token")
|
||||||
|
self.repeater?.pause()
|
||||||
|
self.repeater = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !self.accessToken.isEmpty {
|
||||||
|
self.repeater?.pause()
|
||||||
|
self.repeater = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.repeater?.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func logout() {
|
public func logout() {
|
||||||
self.accessToken = ""
|
self.accessToken = ""
|
||||||
self.refreshToken = ""
|
self.refreshToken = ""
|
||||||
@@ -175,6 +218,95 @@ public class RemoteAuth {
|
|||||||
}.resume()
|
}.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func registerDevice(completion: @escaping (DeviceResponse?) -> Void) {
|
||||||
|
guard let url = URL(string: "\(Remote.host)/auth/device") else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let body = "client_id=\(self.clientID)"
|
||||||
|
.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 resp = try? JSONDecoder().decode(DeviceResponse.self, from: data) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(resp)
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollForToken(completion: @escaping (Error?) -> 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 = "client_id=\(self.clientID)&device_code=\(self.deviceCode)&grant_type=urn:ietf:params:oauth:grant-type:device_code"
|
||||||
|
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||||
|
request.httpBody = body?.data(using: .utf8)
|
||||||
|
|
||||||
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||||
|
if let error = error {
|
||||||
|
completion(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
completion(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 200 {
|
||||||
|
guard let data = data else {
|
||||||
|
completion(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data returned"]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||||
|
NotificationCenter.default.post(name: .remoteLoginSuccess, object: nil, userInfo: [
|
||||||
|
"access_token": result.access_token,
|
||||||
|
"refresh_token": result.refresh_token
|
||||||
|
])
|
||||||
|
completion(nil)
|
||||||
|
} catch {
|
||||||
|
completion(error)
|
||||||
|
}
|
||||||
|
} else if httpResponse.statusCode == 400 {
|
||||||
|
guard let data = data, let responseString = String(data: data, encoding: .utf8) else {
|
||||||
|
completion(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Bad request"]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseString.contains("authorization_pending") {
|
||||||
|
completion(nil)
|
||||||
|
} else if responseString.contains("expired_token") {
|
||||||
|
completion(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Device code expired, please re-register"]))
|
||||||
|
} else if responseString.contains("slow_down") {
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + Double(self.interval)) {
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
completion(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: responseString]))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown error"
|
||||||
|
completion(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to get token (\(httpResponse.statusCode)): \(errorMessage)"]))
|
||||||
|
}
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func successLogin(_ notification: Notification) {
|
@objc private func successLogin(_ notification: Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accessToken = userInfo["access_token"] as? String,
|
let accessToken = userInfo["access_token"] as? String,
|
||||||
|
|||||||
@@ -418,3 +418,10 @@ public struct TokenResponse: Codable {
|
|||||||
public let access_token: String
|
public let access_token: String
|
||||||
public let refresh_token: String
|
public let refresh_token: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct DeviceResponse: Codable {
|
||||||
|
public let device_code: String
|
||||||
|
public let user_code: String
|
||||||
|
public let verification_uri_complete: URL
|
||||||
|
public let interval: Int?
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
5C038CF62D86EE8A00516809 /* Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C038CF52D86EE8700516809 /* Remote.swift */; };
|
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 */; };
|
5C044F7A2B3DE6F3005F6951 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C044F792B3DE6F3005F6951 /* portal.swift */; };
|
||||||
5C0A2A8A292A5B4D009B4C1F /* SMJobBlessUtil.py in Resources */ = {isa = PBXBuildFile; fileRef = 5C0A2A89292A5B4D009B4C1F /* SMJobBlessUtil.py */; };
|
5C0A2A8A292A5B4D009B4C1F /* SMJobBlessUtil.py in Resources */ = {isa = PBXBuildFile; fileRef = 5C0A2A89292A5B4D009B4C1F /* SMJobBlessUtil.py */; };
|
||||||
5C0A9CA22C467AA300EE6A89 /* widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0A9CA12C467AA300EE6A89 /* widget.swift */; };
|
5C0A9CA22C467AA300EE6A89 /* widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0A9CA12C467AA300EE6A89 /* widget.swift */; };
|
||||||
@@ -498,7 +497,6 @@
|
|||||||
4921436D25319699000A1C47 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
5C0A9CA12C467AA300EE6A89 /* widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widget.swift; sourceTree = "<group>"; };
|
||||||
@@ -1117,7 +1115,6 @@
|
|||||||
9A81C74A24499C4B00825D92 /* Views */ = {
|
9A81C74A24499C4B00825D92 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
5C038CF72D8702D600516809 /* Login.swift */,
|
|
||||||
9A81C74C24499C7000825D92 /* Settings.swift */,
|
9A81C74C24499C7000825D92 /* Settings.swift */,
|
||||||
9A045EB62594F8D100ED58F2 /* Dashboard.swift */,
|
9A045EB62594F8D100ED58F2 /* Dashboard.swift */,
|
||||||
9A81C74B24499C7000825D92 /* AppSettings.swift */,
|
9A81C74B24499C7000825D92 /* AppSettings.swift */,
|
||||||
@@ -2063,7 +2060,6 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
9AABEB7A243FD26200668CB0 /* AppDelegate.swift in Sources */,
|
9AABEB7A243FD26200668CB0 /* AppDelegate.swift in Sources */,
|
||||||
5C038CF82D8702D800516809 /* Login.swift in Sources */,
|
|
||||||
9A9EA9452476D34500E3B883 /* Update.swift in Sources */,
|
9A9EA9452476D34500E3B883 /* Update.swift in Sources */,
|
||||||
9A045EB72594F8D100ED58F2 /* Dashboard.swift in Sources */,
|
9A045EB72594F8D100ED58F2 /* Dashboard.swift in Sources */,
|
||||||
9A81C74E24499C7000825D92 /* Settings.swift in Sources */,
|
9A81C74E24499C7000825D92 /* Settings.swift in Sources */,
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ class ApplicationSettings: NSStackView {
|
|||||||
|
|
||||||
private let updateWindow: UpdateWindow = UpdateWindow()
|
private let updateWindow: UpdateWindow = UpdateWindow()
|
||||||
private let moduleSelector: ModuleSelectorView = ModuleSelectorView()
|
private let moduleSelector: ModuleSelectorView = ModuleSelectorView()
|
||||||
private let loginWindow: LoginWindow = LoginWindow()
|
|
||||||
|
|
||||||
private var CPUeButton: NSButton?
|
private var CPUeButton: NSButton?
|
||||||
private var CPUpButton: NSButton?
|
private var CPUpButton: NSButton?
|
||||||
@@ -438,7 +437,7 @@ class ApplicationSettings: NSStackView {
|
|||||||
self.remoteView?.setRowVisibility(2, newState: true)
|
self.remoteView?.setRowVisibility(2, newState: true)
|
||||||
return
|
return
|
||||||
} else if state && !auth {
|
} else if state && !auth {
|
||||||
self.loginWindow.open()
|
Remote.shared.login()
|
||||||
}
|
}
|
||||||
self.remoteBtn?.state = .off
|
self.remoteBtn?.state = .off
|
||||||
self.remoteView?.setRowVisibility(1, newState: false)
|
self.remoteView?.setRowVisibility(1, newState: false)
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
//
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user