fix token history

This commit is contained in:
anlicheng 2026-05-11 12:02:03 +08:00
parent 9f1eadeaaa
commit 78f8752aa7
3 changed files with 221 additions and 11 deletions

2
dmg.sh
View File

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

View File

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

View File

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