fix
This commit is contained in:
parent
f9b1c03b85
commit
20993dd923
77
punchnet/Core/KeychainStore.swift
Normal file
77
punchnet/Core/KeychainStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) ?? "")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
Text("密钥登陆")
|
.clipped()
|
||||||
.foregroundColor(state.loginMode == .token ? .blue : .black)
|
.frame(width: 25, height: 25)
|
||||||
|
|
||||||
|
Text("密钥登陆")
|
||||||
|
.foregroundColor(self.loginMode == .token ? .blue : .black)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.buttonStyle(.plain)
|
||||||
.onTapGesture {
|
|
||||||
self.state.loginMode = .token
|
Button {
|
||||||
}
|
self.loginMode = .account
|
||||||
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image("logo")
|
Image("logo")
|
||||||
.resizable()
|
.resizable()
|
||||||
.clipped()
|
.clipped()
|
||||||
.frame(width: 25, height: 25)
|
.frame(width: 25, height: 25)
|
||||||
Text("账户登陆")
|
Text("账户登陆")
|
||||||
.foregroundColor(state.loginMode == .account ? .blue : .black)
|
.foregroundColor(self.loginMode == .account ? .blue : .black)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
|
||||||
self.state.loginMode = .account
|
|
||||||
}
|
}
|
||||||
|
.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)
|
|
||||||
.multilineTextAlignment(.leading)
|
SecureField("密码", text: $password)
|
||||||
.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)
|
||||||
Rectangle()
|
|
||||||
.overlay {
|
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("登陆")
|
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 = "内部错误,请稍后重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
punchnet/Views/RootView.swift
Normal file
28
punchnet/Views/RootView.swift
Normal 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()
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user