diff --git a/.swiftlint.yml b/.swiftlint.yml index 4ed5d06d..afd09fbc 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -45,6 +45,10 @@ identifier_name: - SensorsWidgetValue - access_token - refresh_token + - device_code + - user_code + - verification_uri_complete + - expires_in line_length: 200 diff --git a/Kit/plugins/Remote.swift b/Kit/plugins/Remote.swift index 69306f1f..bfaeafc3 100644 --- a/Kit/plugins/Remote.swift +++ b/Kit/plugins/Remote.swift @@ -14,7 +14,7 @@ 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 + static public var host = URL(string: "https://api.system-stats.com")! // https://api.system-stats.com http://localhost:8008 public var state: Bool { get { Store.shared.bool(key: "remote_state", defaultValue: false) } @@ -54,6 +54,13 @@ public class Remote { 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() { self.auth.logout() self.isAuthorized = false @@ -105,6 +112,12 @@ public class RemoteAuth { get { Store.shared.string(key: "refresh_token", defaultValue: "") } 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() { NotificationCenter.default.addObserver(self, selector: #selector(self.successLogin), name: .remoteLoginSuccess, object: nil) @@ -118,6 +131,36 @@ public class RemoteAuth { 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() { self.accessToken = "" self.refreshToken = "" @@ -175,6 +218,95 @@ public class RemoteAuth { }.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) { guard let userInfo = notification.userInfo, let accessToken = userInfo["access_token"] as? String, diff --git a/Kit/types.swift b/Kit/types.swift index 4553c53f..1c25c979 100644 --- a/Kit/types.swift +++ b/Kit/types.swift @@ -418,3 +418,10 @@ public struct TokenResponse: Codable { public let access_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? +} diff --git a/Stats.xcodeproj/project.pbxproj b/Stats.xcodeproj/project.pbxproj index 98f0559f..8afc8763 100644 --- a/Stats.xcodeproj/project.pbxproj +++ b/Stats.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* 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 */; }; @@ -498,7 +497,6 @@ 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 = ""; }; @@ -1117,7 +1115,6 @@ 9A81C74A24499C4B00825D92 /* Views */ = { isa = PBXGroup; children = ( - 5C038CF72D8702D600516809 /* Login.swift */, 9A81C74C24499C7000825D92 /* Settings.swift */, 9A045EB62594F8D100ED58F2 /* Dashboard.swift */, 9A81C74B24499C7000825D92 /* AppSettings.swift */, @@ -2063,7 +2060,6 @@ 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 */, diff --git a/Stats/Views/AppSettings.swift b/Stats/Views/AppSettings.swift index efc4e5c1..49b78fa4 100644 --- a/Stats/Views/AppSettings.swift +++ b/Stats/Views/AppSettings.swift @@ -50,7 +50,6 @@ class ApplicationSettings: NSStackView { private let updateWindow: UpdateWindow = UpdateWindow() private let moduleSelector: ModuleSelectorView = ModuleSelectorView() - private let loginWindow: LoginWindow = LoginWindow() private var CPUeButton: NSButton? private var CPUpButton: NSButton? @@ -438,7 +437,7 @@ class ApplicationSettings: NSStackView { self.remoteView?.setRowVisibility(2, newState: true) return } else if state && !auth { - self.loginWindow.open() + Remote.shared.login() } self.remoteBtn?.state = .off self.remoteView?.setRowVisibility(1, newState: false) diff --git a/Stats/Views/Login.swift b/Stats/Views/Login.swift deleted file mode 100644 index ce71cb4a..00000000 --- a/Stats/Views/Login.swift +++ /dev/null @@ -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() - } -}