From 20993dd923ffe8adf246fd23c569cce5afbe6f9d Mon Sep 17 00:00:00 2001 From: anlicheng <244108715@qq.com> Date: Mon, 19 Jan 2026 17:36:46 +0800 Subject: [PATCH] fix --- punchnet/Core/KeychainStore.swift | 77 ++++++++++ punchnet/Core/SDLAPI.swift | 52 ++++++- punchnet/Views/Login/LoginState.swift | 15 +- punchnet/Views/Login/LoginView.swift | 197 ++++++++++++++++++-------- punchnet/Views/RootView.swift | 28 ++++ punchnet/punchnet.entitlements | 2 + punchnet/punchnetApp.swift | 2 +- 7 files changed, 299 insertions(+), 74 deletions(-) create mode 100644 punchnet/Core/KeychainStore.swift create mode 100644 punchnet/Views/RootView.swift diff --git a/punchnet/Core/KeychainStore.swift b/punchnet/Core/KeychainStore.swift new file mode 100644 index 0000000..dd35cd0 --- /dev/null +++ b/punchnet/Core/KeychainStore.swift @@ -0,0 +1,77 @@ +// +// KeychainStore.swift +// punchnet +// +// Created by 安礼成 on 2026/1/19. +// + +import Foundation +import Security + +enum KeychainError: Error { + case unexpectedStatus(OSStatus) +} + +final class KeychainStore { + + public static var shared: KeychainStore = .init(service: Bundle.main.bundleIdentifier!) + + private let service: String + + private init(service: String) { + self.service = service + } + + func save(_ data: Data, account: String) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecValueData: data + ] + + // 先删再加,避免重复 + SecItemDelete(query as CFDictionary) + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) + } + } + + func load(account: String) throws -> Data? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) + } + + return result as? Data + } + + func delete(account: String) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + } +} diff --git a/punchnet/Core/SDLAPI.swift b/punchnet/Core/SDLAPI.swift index 48aacec..3c72a67 100644 --- a/punchnet/Core/SDLAPI.swift +++ b/punchnet/Core/SDLAPI.swift @@ -12,7 +12,7 @@ struct JSONRPCResponse: Decodable { let error: JSONRPCError? } -struct JSONRPCError: Decodable { +struct JSONRPCError: Error, Decodable { let code: Int let message: String let data: String? @@ -20,8 +20,21 @@ struct JSONRPCError: Decodable { struct SDLAPI { - static let baseUrl: String = "https://punchnet.s5s8.com/api" - static let testBaseUrl: String = "http://127.0.0.1:19082/test" + enum Mode { + case debug + case prod + } + + static let mode: Mode = .debug + + static var baseUrl: String { + switch mode { + case .debug: + return "http://127.0.0.1:18082/test" + case .prod: + return "https://punchnet.s5s8.com/api" + } + } struct Upgrade: Decodable { let upgrade_type: Int @@ -71,4 +84,37 @@ struct SDLAPI { return try JSONDecoder().decode(JSONRPCResponse.self, from: data) } + static func loginWithAccountAndPassword(clientId: String, username: String, password: String, as: T.Type) async throws -> T { + let params: [String:Any] = [ + "client_id": clientId, + "username": username, + "password": password + ] + + return try await doPost(path: "/login_with_account", params: params, as: T.self) + } + + private static func doPost(path: String, params: [String: Any], as: T.Type) async throws -> T { + let postData = try! JSONSerialization.data(withJSONObject: params) + var request = URLRequest(url: URL(string: baseUrl + path)!) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = postData + + let (data, _) = try await URLSession.shared.data(for: request) + let rpcResponse = try JSONDecoder().decode(JSONRPCResponse.self, from: data) + if let result = rpcResponse.result { + return result + } else if let error = rpcResponse.error { + throw error + } else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: "Invalid JSON-RPC response: \(String(data: data, encoding: .utf8) ?? "")" + ) + ) + } + } + } diff --git a/punchnet/Views/Login/LoginState.swift b/punchnet/Views/Login/LoginState.swift index 607d77a..a928255 100644 --- a/punchnet/Views/Login/LoginState.swift +++ b/punchnet/Views/Login/LoginState.swift @@ -9,14 +9,13 @@ import Foundation import Observation @Observable -class LoginState { - enum LoginMode { - case token - case account +class UserContext { + var isLogined: Bool = false + var loginCredit: LoginCredit? + + enum LoginCredit { + case token(token: String, networkdId: Int) + case accountAndPasword(account: String, password: String, networkId: Int) } - var token: String = "" - var account: String = "" - var password: String = "" - var loginMode: LoginMode = .account } diff --git a/punchnet/Views/Login/LoginView.swift b/punchnet/Views/Login/LoginView.swift index fa0c76c..ed8f68b 100644 --- a/punchnet/Views/Login/LoginView.swift +++ b/punchnet/Views/Login/LoginView.swift @@ -10,47 +10,57 @@ import Observation // 登陆页面 struct LoginView: View { - @State private var state = LoginState() + @State private var loginMode: LoginMode = .account + + enum LoginMode { + case token + case account + } var body: some View { VStack { Text("PunchNet") + .font(.system(size: 16, weight: .medium)) HStack(alignment: .center, spacing: 30) { - HStack { - Image("logo") - .resizable() - .clipped() - .frame(width: 25, height: 25) - - Text("密钥登陆") - .foregroundColor(state.loginMode == .token ? .blue : .black) + Button { + self.loginMode = .token + } label: { + HStack { + Image("logo") + .resizable() + .clipped() + .frame(width: 25, height: 25) + + Text("密钥登陆") + .foregroundColor(self.loginMode == .token ? .blue : .black) + } + .contentShape(Rectangle()) } - .contentShape(Rectangle()) - .onTapGesture { - self.state.loginMode = .token - } - - HStack { - Image("logo") - .resizable() - .clipped() - .frame(width: 25, height: 25) - Text("账户登陆") - .foregroundColor(state.loginMode == .account ? .blue : .black) - } - .contentShape(Rectangle()) - .onTapGesture { - self.state.loginMode = .account + .buttonStyle(.plain) + + Button { + self.loginMode = .account + } label: { + HStack { + Image("logo") + .resizable() + .clipped() + .frame(width: 25, height: 25) + Text("账户登陆") + .foregroundColor(self.loginMode == .account ? .blue : .black) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) } Group { - switch state.loginMode { + switch loginMode { case .token: - LoginTokenView(state: self.state) + LoginTokenView() case .account: - LoginAccountView(state: self.state) + LoginAccountView() } } @@ -62,10 +72,11 @@ struct LoginView: View { } struct LoginTokenView: View { - @Bindable var state: LoginState + @Environment(UserContext.self) var userContext: UserContext + @State private var token: String = "" var body: some View { - TextField("认证密钥", text: self.$state.token) + TextField("认证密钥", text: $token) .multilineTextAlignment(.leading) .textFieldStyle(PlainTextFieldStyle()) .frame(width: 200, height: 25) @@ -95,47 +106,109 @@ struct LoginTokenView: View { } struct LoginAccountView: View { - @Bindable var state: LoginState + @Environment(UserContext.self) var userContext: UserContext + + @State private var username: String = "" + @State private var password: String = "" + + @State private var showAlert = false + @State private var errorMessage = "" + + struct LoginResult: Decodable { + var accessToken: String + var networkId: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case networkId = "network_id" + } + } var body: some View { - TextField("手机号/邮箱", text: $state.account) - .multilineTextAlignment(.leading) - .textFieldStyle(PlainTextFieldStyle()) - .frame(width: 200, height: 25) - .background(Color.clear) - .foregroundColor(Color.black) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(.blue) - .padding(.top, 25) - , alignment: .topLeading) - - SecureField("密码", text: $state.password) - .multilineTextAlignment(.leading) - .textFieldStyle(PlainTextFieldStyle()) - .frame(width: 200, height: 25) - .background(Color.clear) - .foregroundColor(Color.black) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(.blue) - .padding(.top, 25) - , alignment: .topLeading) - - Rectangle() - .overlay { + VStack { + TextField("手机号/邮箱", text: $username) + .multilineTextAlignment(.leading) + .textFieldStyle(PlainTextFieldStyle()) + .frame(width: 200, height: 25) + .background(Color.clear) + .foregroundColor(Color.black) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(.blue) + .padding(.top, 25) + , alignment: .topLeading) + + SecureField("密码", text: $password) + .multilineTextAlignment(.leading) + .textFieldStyle(PlainTextFieldStyle()) + .frame(width: 200, height: 25) + .background(Color.clear) + .foregroundColor(Color.black) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(.blue) + .padding(.top, 25) + , alignment: .topLeading) + + Button { + if self.username.isEmpty { + self.showAlert = true + self.errorMessage = "账号不能为空" + } else if self.password.isEmpty { + self.showAlert = true + self.errorMessage = "密码不能为空" + } else { + Task { + await self.doLogin() + } + } + } label: { Text("登陆") .font(.system(size: 14, weight: .regular)) .foregroundColor(.black) + .frame(width: 120, height: 35) } .frame(width: 120, height: 35) - .foregroundColor(Color(red: 74 / 255, green: 207 / 255, blue: 154 / 255)) + .background(Color(red: 74 / 255, green: 207 / 255, blue: 154 / 255)) .cornerRadius(5.0) - .onTapGesture { - print("call me here") + } + .alert(isPresented: $showAlert) { + Alert(title: Text("错误提示"), message: Text("账号密码为空")) + } + } + + // 执行登陆操作 + private func doLogin() async { + let clientId = SystemConfig.getClientId() + do { + let loginResult = try await SDLAPI.loginWithAccountAndPassword(clientId: clientId, username: self.username, password: self.password, as: LoginResult.self) + + print(loginResult.accessToken) + + // 保存信息到KeychainStore + let store = KeychainStore.shared + if let tokenData = loginResult.accessToken.data(using: .utf8) { + try store.save(tokenData, account: self.username) } + + await MainActor.run { + self.userContext.isLogined = true + self.userContext.loginCredit = .accountAndPasword(account: username, password: password, networkId: loginResult.networkId) + } + + } catch let err as JSONRPCError { + await MainActor.run { + self.showAlert = true + self.errorMessage = err.message + } + } catch { + await MainActor.run { + self.showAlert = true + self.errorMessage = "内部错误,请稍后重试" + } + } } } diff --git a/punchnet/Views/RootView.swift b/punchnet/Views/RootView.swift new file mode 100644 index 0000000..8336cf0 --- /dev/null +++ b/punchnet/Views/RootView.swift @@ -0,0 +1,28 @@ +// +// RootView.swift +// punchnet +// +// Created by 安礼成 on 2026/1/19. +// + +import SwiftUI + +struct RootView: View { + @State private var userContext = UserContext() + + var body: some View { + Group { + if userContext.isLogined { + NetworkView() + .environment(userContext) + } else { + LoginView() + .environment(userContext) + } + } + } +} + +#Preview { + RootView() +} diff --git a/punchnet/punchnet.entitlements b/punchnet/punchnet.entitlements index f16491e..e196038 100644 --- a/punchnet/punchnet.entitlements +++ b/punchnet/punchnet.entitlements @@ -22,5 +22,7 @@ com.apple.security.network.server + keychain-access-groups + diff --git a/punchnet/punchnetApp.swift b/punchnet/punchnetApp.swift index 3011dbe..e15a9a7 100644 --- a/punchnet/punchnetApp.swift +++ b/punchnet/punchnetApp.swift @@ -45,7 +45,7 @@ struct punchnetApp: App { var body: some Scene { WindowGroup(id: "mainWindow") { //IndexView(noticeServer: self.noticeServer) - NetworkView() + RootView() .onAppear { // 获取主屏幕的尺寸 guard let screenFrame = NSScreen.main?.frame else { return }