fix token history
This commit is contained in:
parent
9f1eadeaaa
commit
78f8752aa7
2
dmg.sh
2
dmg.sh
@ -1,3 +1,3 @@
|
|||||||
#! /bin/sh
|
#! /bin/sh
|
||||||
|
|
||||||
create-dmg --volname "punchnet" --window-pos 200 120 --window-size 800 400 --icon "punchnet.app" 200 190 --hide-extension "punchnet.app" --app-drop-link 600 185 ~/Desktop/punchnet.dmg /Users/anlicheng/Desktop/punchnetv6
|
create-dmg --volname "punchnet" --window-pos 200 120 --window-size 800 400 --icon "punchnet.app" 200 190 --hide-extension "punchnet.app" --app-drop-link 600 185 ~/Desktop/punchnet.dmg /Users/anlicheng/Desktop/punchnetv10
|
||||||
|
|||||||
@ -12,8 +12,17 @@ struct AppContextError: Error {
|
|||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TokenHistoryItem: Codable, Hashable {
|
||||||
|
let token: String
|
||||||
|
let networkName: String?
|
||||||
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
class AppContext {
|
class AppContext {
|
||||||
|
private let cachedTokenAccount = "token"
|
||||||
|
private let cachedTokenHistoryAccount = "tokenHistory"
|
||||||
|
private let maxCachedTokenCount = 10
|
||||||
|
|
||||||
private var vpnManager = VPNManager.shared
|
private var vpnManager = VPNManager.shared
|
||||||
|
|
||||||
// 调用 "/connect" 之后的网络信息
|
// 调用 "/connect" 之后的网络信息
|
||||||
@ -77,10 +86,7 @@ class AppContext {
|
|||||||
let networkSession = try await AuthService.loginWithToken(token: token)
|
let networkSession = try await AuthService.loginWithToken(token: token)
|
||||||
self.loginCredit = .token(token: token, session: networkSession)
|
self.loginCredit = .token(token: token, session: networkSession)
|
||||||
self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId)
|
self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId)
|
||||||
// 将数据缓存到keychain
|
try self.saveCacheToken(token, networkName: networkSession.networkName)
|
||||||
if let data = token.data(using: .utf8) {
|
|
||||||
try KeychainStore.shared.save(data, account: "token")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginWith(username: String, password: String) async throws {
|
func loginWith(username: String, password: String) async throws {
|
||||||
@ -158,12 +164,87 @@ class AppContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadCacheToken() -> String? {
|
func loadCacheToken() -> String? {
|
||||||
if let data = try? KeychainStore.shared.load(account: "token") {
|
if let item = self.loadCacheTokenHistory().first {
|
||||||
return String(data: data, encoding: .utf8)
|
return item.token
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadCacheTokenHistory() -> [TokenHistoryItem] {
|
||||||
|
if let data = try? KeychainStore.shared.load(account: cachedTokenHistoryAccount),
|
||||||
|
let items = try? JSONDecoder().decode([TokenHistoryItem].self, from: data) {
|
||||||
|
let normalizedItems = self.normalizedTokenHistory(items)
|
||||||
|
if !normalizedItems.isEmpty {
|
||||||
|
return normalizedItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = try? KeychainStore.shared.load(account: cachedTokenHistoryAccount),
|
||||||
|
let tokens = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
let normalizedItems = self.normalizedTokenHistory(tokens.map {
|
||||||
|
TokenHistoryItem(token: $0, networkName: nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
if !normalizedItems.isEmpty {
|
||||||
|
return normalizedItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = try? KeychainStore.shared.load(account: cachedTokenAccount),
|
||||||
|
let token = String(data: data, encoding: .utf8) {
|
||||||
|
return self.normalizedTokenHistory([
|
||||||
|
TokenHistoryItem(token: token, networkName: nil)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCacheToken(_ token: String, networkName: String) throws {
|
||||||
|
let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !normalizedToken.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedNetworkName = networkName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var items = self.loadCacheTokenHistory()
|
||||||
|
items.removeAll { $0.token == normalizedToken }
|
||||||
|
items.insert(
|
||||||
|
TokenHistoryItem(
|
||||||
|
token: normalizedToken,
|
||||||
|
networkName: normalizedNetworkName.isEmpty ? nil : normalizedNetworkName
|
||||||
|
),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
items = Array(items.prefix(maxCachedTokenCount))
|
||||||
|
|
||||||
|
let historyData = try JSONEncoder().encode(items)
|
||||||
|
try KeychainStore.shared.save(historyData, account: cachedTokenHistoryAccount)
|
||||||
|
|
||||||
|
if let data = normalizedToken.data(using: .utf8) {
|
||||||
|
try KeychainStore.shared.save(data, account: cachedTokenAccount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedTokenHistory(_ items: [TokenHistoryItem]) -> [TokenHistoryItem] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
return items.compactMap { item in
|
||||||
|
let normalizedToken = item.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !normalizedToken.isEmpty, !seen.contains(normalizedToken) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.insert(normalizedToken)
|
||||||
|
let normalizedNetworkName = item.networkName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return TokenHistoryItem(
|
||||||
|
token: normalizedToken,
|
||||||
|
networkName: normalizedNetworkName?.isEmpty == true ? nil : normalizedNetworkName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loadCacheUsernameAndPassword() -> (String, String)? {
|
func loadCacheUsernameAndPassword() -> (String, String)? {
|
||||||
if let data = try? KeychainStore.shared.load(account: "accountAndPasword"),
|
if let data = try? KeychainStore.shared.load(account: "accountAndPasword"),
|
||||||
let str = String(data: data, encoding: .utf8) {
|
let str = String(data: data, encoding: .utf8) {
|
||||||
|
|||||||
@ -191,16 +191,50 @@ struct LoginTokenView: View {
|
|||||||
@Environment(AppContext.self) var appContext: AppContext
|
@Environment(AppContext.self) var appContext: AppContext
|
||||||
|
|
||||||
@State private var token = ""
|
@State private var token = ""
|
||||||
|
@State private var tokenHistory: [TokenHistoryItem] = []
|
||||||
|
@State private var wantsTokenHistory = false
|
||||||
|
@State private var showTokenHistory = false
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
@FocusState private var isTokenFocused: Bool
|
||||||
|
|
||||||
// 错误提示
|
// 错误提示
|
||||||
@State private var showAlert: Bool = false
|
@State private var showAlert: Bool = false
|
||||||
@State private var errorMessage: String = ""
|
@State private var errorMessage: String = ""
|
||||||
|
|
||||||
|
private var normalizedToken: String {
|
||||||
|
token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleTokenHistory: [TokenHistoryItem] {
|
||||||
|
guard !normalizedToken.isEmpty else {
|
||||||
|
return tokenHistory
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchedItems = tokenHistory.filter {
|
||||||
|
$0.token.localizedCaseInsensitiveContains(normalizedToken)
|
||||||
|
|| ($0.networkName?.localizedCaseInsensitiveContains(normalizedToken) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedItems
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tokenHistoryPopoverBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
showTokenHistory
|
||||||
|
},
|
||||||
|
set: { isPresented in
|
||||||
|
showTokenHistory = isPresented
|
||||||
|
if !isPresented {
|
||||||
|
wantsTokenHistory = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
CustomTextField(title: "请输入认证密钥 (Token)", text: $token, icon: "key.fill")
|
tokenInputField
|
||||||
.frame(width: 280)
|
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@ -214,26 +248,121 @@ struct LoginTokenView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.frame(width: 280)
|
.frame(width: 280)
|
||||||
.disabled(token.isEmpty || isLoading)
|
.disabled(normalizedToken.isEmpty || isLoading)
|
||||||
}
|
}
|
||||||
.alert(isPresented: $showAlert) {
|
.alert(isPresented: $showAlert) {
|
||||||
Alert(title: Text("提示"), message: Text(self.errorMessage))
|
Alert(title: Text("提示"), message: Text(self.errorMessage))
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
self.tokenHistory = self.appContext.loadCacheTokenHistory()
|
||||||
if let cacheToken = self.appContext.loadCacheToken() {
|
if let cacheToken = self.appContext.loadCacheToken() {
|
||||||
self.token = cacheToken
|
self.token = cacheToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tokenInputField: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "key.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 20)
|
||||||
|
|
||||||
|
TextField("请输入认证密钥 (Token)", text: $token)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.focused($isTokenFocused)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.frame(width: 280)
|
||||||
|
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2), lineWidth: 1))
|
||||||
|
.onTapGesture {
|
||||||
|
self.presentTokenHistory()
|
||||||
|
}
|
||||||
|
.onChange(of: isTokenFocused) {
|
||||||
|
if isTokenFocused {
|
||||||
|
self.presentTokenHistory()
|
||||||
|
} else if !showTokenHistory {
|
||||||
|
self.wantsTokenHistory = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: token) {
|
||||||
|
if wantsTokenHistory {
|
||||||
|
self.refreshTokenHistoryPopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.popover(isPresented: tokenHistoryPopoverBinding, arrowEdge: .bottom) {
|
||||||
|
tokenHistoryPopover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tokenHistoryPopover: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(visibleTokenHistory, id: \.token) { item in
|
||||||
|
Button(action: {
|
||||||
|
self.token = item.token
|
||||||
|
self.dismissTokenHistory()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: item.token == normalizedToken ? "checkmark" : "key.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 14)
|
||||||
|
|
||||||
|
Text(tokenHistoryTitle(item))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 280)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentTokenHistory() {
|
||||||
|
self.wantsTokenHistory = true
|
||||||
|
self.refreshTokenHistoryPopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissTokenHistory() {
|
||||||
|
self.wantsTokenHistory = false
|
||||||
|
self.showTokenHistory = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshTokenHistoryPopover() {
|
||||||
|
self.tokenHistory = self.appContext.loadCacheTokenHistory()
|
||||||
|
self.showTokenHistory = wantsTokenHistory && !visibleTokenHistory.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tokenHistoryTitle(_ item: TokenHistoryItem) -> String {
|
||||||
|
guard let networkName = item.networkName, !networkName.isEmpty else {
|
||||||
|
return item.token
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(item.token) (\(networkName))"
|
||||||
|
}
|
||||||
|
|
||||||
private func login() async {
|
private func login() async {
|
||||||
|
let finalToken = self.normalizedToken
|
||||||
|
guard !finalToken.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
self.isLoading = true
|
self.isLoading = true
|
||||||
|
self.dismissTokenHistory()
|
||||||
defer {
|
defer {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await appContext.loginWith(token: token)
|
_ = try await appContext.loginWith(token: finalToken)
|
||||||
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
||||||
self.appContext.appScene = .logined
|
self.appContext.appScene = .logined
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user