项目文件夹

文件
2026-07-13 12:22:33 +08:00

492 行
20 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import Foundation
#if os(macOS)
import AppKit
import UserNotifications
/// Manages automatic session keepalive for Augment to prevent cookie expiration.
///
/// This actor monitors cookie expiration and proactively refreshes the session
/// before cookies expire, ensuring uninterrupted access to Augment APIs.
@MainActor
public final class AugmentSessionKeepalive {
// MARK: - Configuration
/// How often to check if session needs refresh (default: 1 minute for faster recovery)
private let checkInterval: TimeInterval = 60
/// Refresh session this many seconds before cookie expiration (default: 5 minutes)
private let refreshBufferSeconds: TimeInterval = 300
/// Minimum time between refresh attempts (default: 1 minute for faster recovery)
private let minRefreshInterval: TimeInterval = 60
/// Maximum time to wait for session refresh (default: 30 seconds)
private let refreshTimeout: TimeInterval = 30
// MARK: - State
private var timerTask: Task<Void, Never>?
private var lastRefreshAttempt: Date?
private var lastSuccessfulRefresh: Date?
private var isRefreshing = false
private let logger: ((String) -> Void)?
private var onSessionRecovered: (() async -> Void)?
/// Track consecutive failures to stop retrying after too many failures
private var consecutiveFailures = 0
private let maxConsecutiveFailures = 3 // Stop after 3 failures
private var hasGivenUp = false
// MARK: - Initialization
public init(logger: ((String) -> Void)? = nil, onSessionRecovered: (() async -> Void)? = nil) {
self.logger = logger
self.onSessionRecovered = onSessionRecovered
}
deinit {
self.timerTask?.cancel()
}
// MARK: - Public API
/// Start the automatic session keepalive timer
public func start() {
guard self.timerTask == nil else {
self.log("Keepalive already running")
return
}
self.log("🚀 Starting Augment session keepalive")
self.log(
" - Check interval: \(Int(self.checkInterval))s "
+ "(\(Self.durationDescription(seconds: self.checkInterval)))")
self.log(
" - Refresh buffer: \(Int(self.refreshBufferSeconds))s "
+ "(\(Self.durationDescription(seconds: self.refreshBufferSeconds)) before expiry)")
self.log(
" - Min refresh interval: \(Int(self.minRefreshInterval))s "
+ "(\(Self.durationDescription(seconds: self.minRefreshInterval)))")
self.timerTask = Task.detached(priority: .utility) { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(self?.checkInterval ?? 300))
await self?.checkAndRefreshIfNeeded()
}
}
self.log("✅ Keepalive timer started successfully")
}
/// Stop the automatic session keepalive timer
public func stop() {
self.log("Stopping Augment session keepalive")
self.timerTask?.cancel()
self.timerTask = nil
}
/// Manually trigger a session refresh (bypasses rate limiting)
public func forceRefresh() async {
self.log("Force refresh requested")
await self.performRefresh(forced: true)
}
// MARK: - Private Implementation
private func checkAndRefreshIfNeeded() async {
guard !self.isRefreshing else {
self.log("Refresh already in progress, skipping check")
return
}
// Stop trying if we've given up
if self.hasGivenUp {
self.log("⏸️ Keepalive has given up after \(self.maxConsecutiveFailures) consecutive failures")
self.log(" User must manually log in to Augment and click 'Refresh Session'")
return
}
// Rate limit: don't refresh too frequently
if let lastAttempt = self.lastRefreshAttempt {
let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceLastAttempt < self.minRefreshInterval {
self.log(
"Skipping refresh (last attempt \(Int(timeSinceLastAttempt))s ago, " +
"min interval: \(Int(self.minRefreshInterval))s)")
return
}
}
// Check if cookies are about to expire
let shouldRefresh = await self.shouldRefreshSession()
if shouldRefresh {
await self.performRefresh(forced: false)
}
}
private func shouldRefreshSession() async -> Bool {
do {
let session = try AugmentCookieImporter.importSession(logger: self.logger)
self.log("📊 Cookie Status Check:")
self.log(" Total cookies: \(session.cookies.count)")
self.log(" Source: \(session.sourceLabel)")
// Log each cookie's expiration status
for cookie in session.cookies {
if let expiry = cookie.expiresDate {
let timeUntil = expiry.timeIntervalSinceNow
let status = timeUntil > 0 ? "expires in \(Int(timeUntil))s" : "EXPIRED \(Int(-timeUntil))s ago"
self.log(" - \(cookie.name): \(status)")
} else {
self.log(" - \(cookie.name): session cookie (no expiry)")
}
}
// Find the earliest expiration date among session cookies
let expirationDates = session.cookies.compactMap(\.expiresDate)
guard !expirationDates.isEmpty else {
// Session cookies (no expiration) - refresh periodically
self.log(" All cookies are session cookies (no expiration dates)")
if let lastRefresh = self.lastSuccessfulRefresh {
let timeSinceRefresh = Date().timeIntervalSince(lastRefresh)
// Refresh every 30 minutes for session cookies
if timeSinceRefresh > 1800 {
self.log(" ⚠️ Need periodic refresh (\(Int(timeSinceRefresh))s since last refresh)")
return true
} else {
self.log(" ✅ Recently refreshed (\(Int(timeSinceRefresh))s ago)")
return false
}
} else {
// Never refreshed - do it now
self.log(" ⚠️ Never refreshed - doing initial refresh")
return true
}
}
let earliestExpiration = expirationDates.min()!
let timeUntilExpiration = earliestExpiration.timeIntervalSinceNow
let expiringCookie = session.cookies.first { $0.expiresDate == earliestExpiration }
if timeUntilExpiration < self.refreshBufferSeconds {
self.log(" ⚠️ REFRESH NEEDED:")
self.log(" Earliest expiring cookie: \(expiringCookie?.name ?? "unknown")")
self.log(" Time until expiration: \(Int(timeUntilExpiration))s")
self.log(" Refresh threshold: \(Int(self.refreshBufferSeconds))s")
return true
} else {
self.log(" ✅ Session healthy:")
self.log(" Earliest expiring cookie: \(expiringCookie?.name ?? "unknown")")
self.log(" Time until expiration: \(Int(timeUntilExpiration))s")
return false
}
} catch {
self.log("✗ Failed to check session: \(error.localizedDescription)")
return false
}
}
private func performRefresh(forced: Bool) async {
self.isRefreshing = true
self.lastRefreshAttempt = Date()
defer { self.isRefreshing = false }
self.log(forced ? "Performing forced session refresh..." : "Performing automatic session refresh...")
// If this is a forced refresh (user clicked "Refresh Session"), reset failure tracking
if forced {
self.consecutiveFailures = 0
self.hasGivenUp = false
self.log("🔄 Manual refresh - resetting failure tracking")
}
do {
// Step 1: Ping the session endpoint to trigger cookie refresh
let refreshed = try await self.pingSessionEndpoint()
if refreshed {
// Step 2: Re-import cookies from browser
try await Task.sleep(for: .seconds(1)) // Brief delay for browser to update cookies
let newSession = try AugmentCookieImporter.importSession(logger: self.logger)
await AugmentSessionStore.shared.setCookies(newSession.cookies)
CookieHeaderCache.store(
provider: .augment,
cookieHeader: newSession.cookieHeader,
sourceLabel: newSession.sourceLabel)
self.log(
"✅ Session refresh successful - imported \(newSession.cookies.count) cookies " +
"from \(newSession.sourceLabel)")
self.lastSuccessfulRefresh = Date()
// Reset failure tracking on success
self.consecutiveFailures = 0
self.hasGivenUp = false
if let callback = self.onSessionRecovered {
self.log("🔄 Triggering usage refresh after session refresh")
await callback()
}
} else {
self.log("⚠️ Session refresh returned no new cookies")
self.consecutiveFailures += 1
self.checkIfShouldGiveUp()
}
} catch AugmentSessionKeepaliveError.sessionExpired {
self.log("🔐 Session expired - attempting automatic recovery...")
self.consecutiveFailures += 1
if self.consecutiveFailures >= self.maxConsecutiveFailures {
self.log("❌ Too many consecutive failures (\(self.consecutiveFailures)) - giving up")
self.log(" User must manually log in to Augment and click 'Refresh Session'")
self.hasGivenUp = true
self.notifyUserLoginRequired()
} else {
await self.attemptSessionRecovery()
}
} catch {
self.log("✗ Session refresh failed: \(error.localizedDescription)")
self.consecutiveFailures += 1
self.checkIfShouldGiveUp()
}
}
private func checkIfShouldGiveUp() {
if self.consecutiveFailures >= self.maxConsecutiveFailures {
self.log("❌ Too many consecutive failures (\(self.consecutiveFailures)) - giving up")
self.log(" User must manually log in to Augment and click 'Refresh Session'")
self.hasGivenUp = true
self.notifyUserLoginRequired()
}
}
/// Attempt to recover from an expired session by triggering browser re-authentication
private func attemptSessionRecovery() async {
self.log("🔄 Attempting automatic session recovery...")
self.log(" Strategy: Open Augment dashboard to trigger browser re-auth")
#if os(macOS)
// Open the Augment dashboard in the default browser
// This will trigger the browser to re-authenticate if the user is still logged in
if let url = URL(string: "https://app.augmentcode.com") {
_ = await MainActor.run {
NSWorkspace.shared.open(url)
}
self.log(" ✅ Opened Augment dashboard in browser")
self.log(" ⏳ Waiting 5 seconds for browser to re-authenticate...")
// Wait for browser to potentially re-authenticate
try? await Task.sleep(for: .seconds(5))
// Try to import cookies again
do {
let newSession = try AugmentCookieImporter.importSession(logger: self.logger)
self.log(" ✅ Session recovery successful - imported \(newSession.cookies.count) cookies")
self.lastSuccessfulRefresh = Date()
// Verify the session is actually valid by pinging the API
let isValid = try await self.pingSessionEndpoint()
if isValid {
self.log(" ✅ Session verified - recovery complete!")
// Notify UsageStore to refresh Augment usage
if let callback = self.onSessionRecovered {
self.log(" 🔄 Triggering usage refresh after successful recovery")
await callback()
}
} else {
self.log(" ⚠️ Session imported but not yet valid - may need manual login")
self.notifyUserLoginRequired()
}
} catch {
self.log(" ✗ Session recovery failed: \(error.localizedDescription)")
self.log(" ️ User needs to manually log in to Augment")
self.notifyUserLoginRequired()
}
}
#else
self.log(" ✗ Automatic recovery not supported on this platform")
#endif
}
/// Notify the user that they need to log in to Augment
private func notifyUserLoginRequired() {
#if os(macOS)
self.log("📢 Sending notification: Augment session expired")
Task {
let center = UNUserNotificationCenter.current()
// Request authorization if needed
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound])
guard granted else {
self.log("⚠️ Notification permission denied")
return
}
} catch {
self.log("✗ Failed to request notification permission: \(error)")
return
}
// Create notification content
let content = UNMutableNotificationContent()
content.title = "Augment Session Expired"
content.body = "Please log in to app.augmentcode.com to restore your session."
content.sound = .default
// Create trigger (deliver immediately)
let request = UNNotificationRequest(
identifier: "augment-session-expired-\(UUID().uuidString)",
content: content,
trigger: nil)
// Deliver notification
do {
try await center.add(request)
self.log("✅ Notification delivered successfully")
} catch {
self.log("✗ Failed to deliver notification: \(error)")
}
}
#endif
}
/// Ping Augment's session endpoint to trigger cookie refresh
private func pingSessionEndpoint() async throws -> Bool {
// Try to get current cookies first
let currentSession = try? AugmentCookieImporter.importSession(logger: self.logger)
guard let cookieHeader = currentSession?.cookieHeader else {
self.log("No cookies available for session ping")
return false
}
self.log("🔄 Attempting session refresh...")
self.log(" Cookies being sent: \(cookieHeader.prefix(100))...")
// Try multiple endpoints - Augment might use different auth patterns
let endpoints = [
"https://app.augmentcode.com/api/auth/session", // NextAuth pattern
"https://app.augmentcode.com/api/session", // Alternative
"https://app.augmentcode.com/api/user", // User endpoint
]
var receivedUnauthorized = false
for (index, urlString) in endpoints.enumerated() {
self.log(" Trying endpoint \(index + 1)/\(endpoints.count): \(urlString)")
guard let sessionURL = URL(string: urlString) else { continue }
var request = URLRequest(url: sessionURL)
request.timeoutInterval = self.refreshTimeout
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("https://app.augmentcode.com", forHTTPHeaderField: "Origin")
request.setValue("https://app.augmentcode.com", forHTTPHeaderField: "Referer")
do {
let (data, response) = try await ProviderHTTPClient.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
self.log(" ✗ Invalid response type")
continue
}
self.log(" Response: HTTP \(httpResponse.statusCode)")
// Log Set-Cookie headers if present
if let setCookies = httpResponse.allHeaderFields["Set-Cookie"] as? String {
self.log(" Set-Cookie headers received: \(setCookies.prefix(100))...")
}
if httpResponse.statusCode == 200 {
// Check if we got a valid session response
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
self.log(" JSON response keys: \(json.keys.joined(separator: ", "))")
if json["user"] != nil || json["email"] != nil || json["session"] != nil {
self.log(" ✅ Valid session data found!")
return true
} else {
self.log(" ⚠️ 200 OK but no session data in response")
// Try next endpoint
continue
}
} else {
self.log(" ⚠️ 200 OK but response is not JSON")
if let responseText = String(data: data, encoding: .utf8) {
self.log(" Response text: \(responseText.prefix(200))...")
}
continue
}
} else if httpResponse.statusCode == 401 {
self.log(" ✗ 401 Unauthorized - session expired")
receivedUnauthorized = true
// Don't throw immediately - try all endpoints first
continue
} else if httpResponse.statusCode == 404 {
self.log(" ✗ 404 Not Found - trying next endpoint")
continue
} else {
self.log(" ✗ HTTP \(httpResponse.statusCode) - trying next endpoint")
continue
}
} catch {
self.log(" ✗ Request failed: \(error.localizedDescription)")
continue
}
}
// If we got 401 from all endpoints, the session is definitely expired
if receivedUnauthorized {
self.log("⚠️ All endpoints returned 401 - session is expired")
throw AugmentSessionKeepaliveError.sessionExpired
}
self.log("⚠️ All session endpoints failed or returned no valid data")
return false
}
private static let log = CodexBarLog.logger(LogCategories.augmentKeepalive)
private static func durationDescription(seconds: TimeInterval) -> String {
let totalSeconds = max(0, Int(seconds.rounded()))
if totalSeconds >= 60, totalSeconds % 60 == 0 {
let minutes = totalSeconds / 60
return "\(minutes) minute\(minutes == 1 ? "" : "s")"
}
return "\(totalSeconds) second\(totalSeconds == 1 ? "" : "s")"
}
private func log(_ message: String) {
let timestamp = Date().formatted(date: .omitted, time: .standard)
let fullMessage = "[\(timestamp)] [AugmentKeepalive] \(message)"
self.logger?(fullMessage)
Self.log.debug(fullMessage)
}
}
// MARK: - Errors
public enum AugmentSessionKeepaliveError: LocalizedError, Sendable {
case invalidResponse
case sessionExpired
case networkError(String)
public var errorDescription: String? {
switch self {
case .invalidResponse:
"Invalid response from session endpoint"
case .sessionExpired:
"Session has expired"
case let .networkError(message):
"Network error: \(message)"
}
}
}
#endif