This commit is contained in:
anlicheng 2026-01-19 17:36:46 +08:00
parent f9b1c03b85
commit 20993dd923
7 changed files with 299 additions and 74 deletions

View File

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

View File

@ -12,7 +12,7 @@ struct JSONRPCResponse<T: Decodable>: Decodable {
let error: JSONRPCError? let error: JSONRPCError?
} }
struct JSONRPCError: Decodable { struct JSONRPCError: Error, Decodable {
let code: Int let code: Int
let message: String let message: String
let data: String? let data: String?
@ -20,8 +20,21 @@ struct JSONRPCError: Decodable {
struct SDLAPI { struct SDLAPI {
static let baseUrl: String = "https://punchnet.s5s8.com/api" enum Mode {
static let testBaseUrl: String = "http://127.0.0.1:19082/test" 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 { struct Upgrade: Decodable {
let upgrade_type: Int let upgrade_type: Int
@ -71,4 +84,37 @@ struct SDLAPI {
return try JSONDecoder().decode(JSONRPCResponse<NetworkProfile>.self, from: data) return try JSONDecoder().decode(JSONRPCResponse<NetworkProfile>.self, from: data)
} }
static func loginWithAccountAndPassword<T: Decodable>(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<T: Decodable>(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<T>.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) ?? "")"
)
)
}
}
} }

View File

@ -9,14 +9,13 @@ import Foundation
import Observation import Observation
@Observable @Observable
class LoginState { class UserContext {
enum LoginMode { var isLogined: Bool = false
case token var loginCredit: LoginCredit?
case account
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
} }

View File

@ -10,47 +10,57 @@ import Observation
// //
struct LoginView: View { struct LoginView: View {
@State private var state = LoginState() @State private var loginMode: LoginMode = .account
enum LoginMode {
case token
case account
}
var body: some View { var body: some View {
VStack { VStack {
Text("PunchNet") Text("PunchNet")
.font(.system(size: 16, weight: .medium))
HStack(alignment: .center, spacing: 30) { HStack(alignment: .center, spacing: 30) {
HStack { Button {
Image("logo") self.loginMode = .token
.resizable() } label: {
.clipped() HStack {
.frame(width: 25, height: 25) Image("logo")
.resizable()
.clipped()
.frame(width: 25, height: 25)
Text("密钥登陆") Text("密钥登陆")
.foregroundColor(state.loginMode == .token ? .blue : .black) .foregroundColor(self.loginMode == .token ? .blue : .black)
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture {
self.state.loginMode = .token
} }
.buttonStyle(.plain)
HStack { Button {
Image("logo") self.loginMode = .account
.resizable() } label: {
.clipped() HStack {
.frame(width: 25, height: 25) Image("logo")
Text("账户登陆") .resizable()
.foregroundColor(state.loginMode == .account ? .blue : .black) .clipped()
} .frame(width: 25, height: 25)
.contentShape(Rectangle()) Text("账户登陆")
.onTapGesture { .foregroundColor(self.loginMode == .account ? .blue : .black)
self.state.loginMode = .account }
.contentShape(Rectangle())
} }
.buttonStyle(.plain)
} }
Group { Group {
switch state.loginMode { switch loginMode {
case .token: case .token:
LoginTokenView(state: self.state) LoginTokenView()
case .account: case .account:
LoginAccountView(state: self.state) LoginAccountView()
} }
} }
@ -62,10 +72,11 @@ struct LoginView: View {
} }
struct LoginTokenView: View { struct LoginTokenView: View {
@Bindable var state: LoginState @Environment(UserContext.self) var userContext: UserContext
@State private var token: String = ""
var body: some View { var body: some View {
TextField("认证密钥", text: self.$state.token) TextField("认证密钥", text: $token)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.textFieldStyle(PlainTextFieldStyle()) .textFieldStyle(PlainTextFieldStyle())
.frame(width: 200, height: 25) .frame(width: 200, height: 25)
@ -95,47 +106,109 @@ struct LoginTokenView: View {
} }
struct LoginAccountView: 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 { var body: some View {
TextField("手机号/邮箱", text: $state.account) VStack {
.multilineTextAlignment(.leading) TextField("手机号/邮箱", text: $username)
.textFieldStyle(PlainTextFieldStyle()) .multilineTextAlignment(.leading)
.frame(width: 200, height: 25) .textFieldStyle(PlainTextFieldStyle())
.background(Color.clear) .frame(width: 200, height: 25)
.foregroundColor(Color.black) .background(Color.clear)
.overlay( .foregroundColor(Color.black)
Rectangle() .overlay(
.frame(height: 1) Rectangle()
.foregroundColor(.blue) .frame(height: 1)
.padding(.top, 25) .foregroundColor(.blue)
, alignment: .topLeading) .padding(.top, 25)
, alignment: .topLeading)
SecureField("密码", text: $state.password) SecureField("密码", text: $password)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.textFieldStyle(PlainTextFieldStyle()) .textFieldStyle(PlainTextFieldStyle())
.frame(width: 200, height: 25) .frame(width: 200, height: 25)
.background(Color.clear) .background(Color.clear)
.foregroundColor(Color.black) .foregroundColor(Color.black)
.overlay( .overlay(
Rectangle() Rectangle()
.frame(height: 1) .frame(height: 1)
.foregroundColor(.blue) .foregroundColor(.blue)
.padding(.top, 25) .padding(.top, 25)
, alignment: .topLeading) , alignment: .topLeading)
Rectangle() Button {
.overlay { 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("登陆") Text("登陆")
.font(.system(size: 14, weight: .regular)) .font(.system(size: 14, weight: .regular))
.foregroundColor(.black) .foregroundColor(.black)
.frame(width: 120, height: 35)
} }
.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) .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 = "内部错误,请稍后重试"
}
}
} }
} }

View File

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

View File

@ -22,5 +22,7 @@
<true/> <true/>
<key>com.apple.security.network.server</key> <key>com.apple.security.network.server</key>
<true/> <true/>
<key>keychain-access-groups</key>
<array/>
</dict> </dict>
</plist> </plist>

View File

@ -45,7 +45,7 @@ struct punchnetApp: App {
var body: some Scene { var body: some Scene {
WindowGroup(id: "mainWindow") { WindowGroup(id: "mainWindow") {
//IndexView(noticeServer: self.noticeServer) //IndexView(noticeServer: self.noticeServer)
NetworkView() RootView()
.onAppear { .onAppear {
// //
guard let screenFrame = NSScreen.main?.frame else { return } guard let screenFrame = NSScreen.main?.frame else { return }