Files
2026-07-13 12:22:33 +08:00

492 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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