From 78f8752aa7466461bbafcc09a5e7bcabee5be4ce Mon Sep 17 00:00:00 2001 From: anlicheng <244108715@qq.com> Date: Mon, 11 May 2026 12:02:03 +0800 Subject: [PATCH] fix token history --- dmg.sh | 2 +- punchnet/App/AppContext.swift | 93 ++++++++++++- punchnet/Features/Auth/Views/LoginView.swift | 137 ++++++++++++++++++- 3 files changed, 221 insertions(+), 11 deletions(-) diff --git a/dmg.sh b/dmg.sh index 343a743..8544fb0 100755 --- a/dmg.sh +++ b/dmg.sh @@ -1,3 +1,3 @@ #! /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 diff --git a/punchnet/App/AppContext.swift b/punchnet/App/AppContext.swift index b2b104a..ec07c01 100644 --- a/punchnet/App/AppContext.swift +++ b/punchnet/App/AppContext.swift @@ -12,8 +12,17 @@ struct AppContextError: Error { let message: String } +struct TokenHistoryItem: Codable, Hashable { + let token: String + let networkName: String? +} + @Observable class AppContext { + private let cachedTokenAccount = "token" + private let cachedTokenHistoryAccount = "tokenHistory" + private let maxCachedTokenCount = 10 + private var vpnManager = VPNManager.shared // 调用 "/connect" 之后的网络信息 @@ -77,10 +86,7 @@ class AppContext { let networkSession = try await AuthService.loginWithToken(token: token) self.loginCredit = .token(token: token, session: networkSession) self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId) - // 将数据缓存到keychain - if let data = token.data(using: .utf8) { - try KeychainStore.shared.save(data, account: "token") - } + try self.saveCacheToken(token, networkName: networkSession.networkName) } func loginWith(username: String, password: String) async throws { @@ -158,12 +164,87 @@ class AppContext { } func loadCacheToken() -> String? { - if let data = try? KeychainStore.shared.load(account: "token") { - return String(data: data, encoding: .utf8) + if let item = self.loadCacheTokenHistory().first { + return item.token } + 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() + + 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)? { if let data = try? KeychainStore.shared.load(account: "accountAndPasword"), let str = String(data: data, encoding: .utf8) { diff --git a/punchnet/Features/Auth/Views/LoginView.swift b/punchnet/Features/Auth/Views/LoginView.swift index 8bc1d89..d86d47c 100644 --- a/punchnet/Features/Auth/Views/LoginView.swift +++ b/punchnet/Features/Auth/Views/LoginView.swift @@ -191,16 +191,50 @@ struct LoginTokenView: View { @Environment(AppContext.self) var appContext: AppContext @State private var token = "" + @State private var tokenHistory: [TokenHistoryItem] = [] + @State private var wantsTokenHistory = false + @State private var showTokenHistory = false @State private var isLoading = false + @FocusState private var isTokenFocused: Bool // 错误提示 @State private var showAlert: Bool = false @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 { + Binding( + get: { + showTokenHistory + }, + set: { isPresented in + showTokenHistory = isPresented + if !isPresented { + wantsTokenHistory = false + } + } + ) + } + var body: some View { VStack(spacing: 20) { - CustomTextField(title: "请输入认证密钥 (Token)", text: $token, icon: "key.fill") - .frame(width: 280) + tokenInputField Button(action: { Task { @MainActor in @@ -214,26 +248,121 @@ struct LoginTokenView: View { .buttonStyle(.borderedProminent) .controlSize(.large) .frame(width: 280) - .disabled(token.isEmpty || isLoading) + .disabled(normalizedToken.isEmpty || isLoading) } .alert(isPresented: $showAlert) { Alert(title: Text("提示"), message: Text(self.errorMessage)) } .onAppear { + self.tokenHistory = self.appContext.loadCacheTokenHistory() if let cacheToken = self.appContext.loadCacheToken() { 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 { + let finalToken = self.normalizedToken + guard !finalToken.isEmpty else { + return + } + self.isLoading = true + self.dismissTokenHistory() defer { self.isLoading = false } do { - _ = try await appContext.loginWith(token: token) + _ = try await appContext.loginWith(token: finalToken) withAnimation(.spring(duration: 0.6, bounce: 0.2)) { self.appContext.appScene = .logined }