1398 lines
53 KiB
Swift
1398 lines
53 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
#if canImport(Darwin)
|
|
import Darwin
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
#elseif canImport(Musl)
|
|
import Musl
|
|
#endif
|
|
|
|
public struct GeminiModelQuota: Sendable {
|
|
public let modelId: String
|
|
public let percentLeft: Double
|
|
public let resetTime: Date?
|
|
public let resetDescription: String?
|
|
}
|
|
|
|
public struct GeminiStatusSnapshot: Sendable {
|
|
public let modelQuotas: [GeminiModelQuota]
|
|
public let rawText: String
|
|
public let accountEmail: String?
|
|
public let accountPlan: String?
|
|
|
|
// Convenience: lowest quota across all models (for icon display)
|
|
public var lowestPercentLeft: Double? {
|
|
self.modelQuotas.min(by: { $0.percentLeft < $1.percentLeft })?.percentLeft
|
|
}
|
|
|
|
/// Legacy compatibility
|
|
public var dailyPercentLeft: Double? {
|
|
self.lowestPercentLeft
|
|
}
|
|
|
|
public var resetDescription: String? {
|
|
self.modelQuotas.min(by: { $0.percentLeft < $1.percentLeft })?.resetDescription
|
|
}
|
|
|
|
/// Converts Gemini quotas to a unified UsageSnapshot.
|
|
/// Groups quotas by tier: Pro (24h window) as primary, Flash (24h window) as secondary,
|
|
/// Flash Lite (24h window) as tertiary.
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
let lower = self.modelQuotas.map { ($0.modelId.lowercased(), $0) }
|
|
let flashLiteQuotas = lower.filter { Self.isFlashLiteModel(id: $0.0) }.map(\.1)
|
|
let flashQuotas = lower.filter { Self.isFlashModel(id: $0.0) }.map(\.1)
|
|
let proQuotas = lower.filter { Self.isProModel(id: $0.0) }.map(\.1)
|
|
|
|
let flashLiteMin = flashLiteQuotas.min(by: { $0.percentLeft < $1.percentLeft })
|
|
let flashMin = flashQuotas.min(by: { $0.percentLeft < $1.percentLeft })
|
|
let proMin = proQuotas.min(by: { $0.percentLeft < $1.percentLeft })
|
|
|
|
// Keep missing tiers nil so downstream selectors can fall back to a quota the account actually reports.
|
|
let primary: RateWindow? = proMin.map {
|
|
RateWindow(
|
|
usedPercent: 100 - $0.percentLeft,
|
|
windowMinutes: 1440,
|
|
resetsAt: $0.resetTime,
|
|
resetDescription: $0.resetDescription)
|
|
}
|
|
|
|
let secondary: RateWindow? = flashMin.map {
|
|
RateWindow(
|
|
usedPercent: 100 - $0.percentLeft,
|
|
windowMinutes: 1440,
|
|
resetsAt: $0.resetTime,
|
|
resetDescription: $0.resetDescription)
|
|
}
|
|
let tertiary: RateWindow? = flashLiteMin.map {
|
|
RateWindow(
|
|
usedPercent: 100 - $0.percentLeft,
|
|
windowMinutes: 1440,
|
|
resetsAt: $0.resetTime,
|
|
resetDescription: $0.resetDescription)
|
|
}
|
|
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .gemini,
|
|
accountEmail: self.accountEmail,
|
|
accountOrganization: nil,
|
|
loginMethod: self.accountPlan)
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
tertiary: tertiary,
|
|
updatedAt: Date(),
|
|
identity: identity)
|
|
}
|
|
|
|
private static func isFlashLiteModel(id: String) -> Bool {
|
|
id.contains("flash-lite")
|
|
}
|
|
|
|
private static func isFlashModel(id: String) -> Bool {
|
|
id.contains("flash") && !self.isFlashLiteModel(id: id)
|
|
}
|
|
|
|
private static func isProModel(id: String) -> Bool {
|
|
id.contains("pro")
|
|
}
|
|
}
|
|
|
|
public enum GeminiStatusProbeError: LocalizedError, Sendable, Equatable {
|
|
case geminiNotInstalled
|
|
case notLoggedIn
|
|
case unsupportedAuthType(String)
|
|
case consumerTierDeprecated
|
|
case parseFailed(String)
|
|
case timedOut
|
|
case apiError(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .geminiNotInstalled:
|
|
"Gemini CLI is not installed or not on PATH."
|
|
case .notLoggedIn:
|
|
"Not logged in to Gemini. Run 'gemini' in Terminal to authenticate."
|
|
case let .unsupportedAuthType(authType):
|
|
"Gemini \(authType) auth not supported. Use Google account (OAuth) instead."
|
|
case .consumerTierDeprecated:
|
|
GeminiConsumerTierMigration.deprecationError
|
|
case let .parseFailed(msg):
|
|
"Could not parse Gemini usage: \(msg)"
|
|
case .timedOut:
|
|
"Gemini quota API request timed out."
|
|
case let .apiError(msg):
|
|
"Gemini API error: \(msg)"
|
|
}
|
|
}
|
|
|
|
/// Detects Google's consumer-tier Gemini CLI shutdown responses.
|
|
public static func isConsumerTierDeprecationSignal(_ text: String) -> Bool {
|
|
let normalized = text.lowercased()
|
|
if normalized.contains("unsupported_client") {
|
|
return true
|
|
}
|
|
if normalized.contains("ineligibletiererror") {
|
|
return true
|
|
}
|
|
if normalized.contains("no longer supported"), normalized.contains("gemini code assist") {
|
|
return true
|
|
}
|
|
if normalized.contains("migrate"), normalized.contains("antigravity"), normalized.contains("gemini") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
public static func throwIfConsumerTierDeprecated(data: Data) throws {
|
|
guard let text = String(data: data, encoding: .utf8),
|
|
isConsumerTierDeprecationSignal(text)
|
|
else {
|
|
return
|
|
}
|
|
throw GeminiStatusProbeError.consumerTierDeprecated
|
|
}
|
|
}
|
|
|
|
public enum GeminiAuthType: String, Sendable {
|
|
case oauthPersonal = "oauth-personal"
|
|
case apiKey = "gemini-api-key"
|
|
case vertexAI = "vertex-ai"
|
|
case unknown
|
|
}
|
|
|
|
/// User tier IDs returned from the Cloud Code Private API (loadCodeAssist).
|
|
/// Maps to: google3/cloud/developer_experience/cloudcode/pa/service/usertier.go
|
|
public enum GeminiUserTierId: String, Sendable {
|
|
case free = "free-tier"
|
|
case legacy = "legacy-tier"
|
|
case standard = "standard-tier"
|
|
}
|
|
|
|
public struct GeminiStatusProbe: Sendable {
|
|
public var timeout: TimeInterval = 10.0
|
|
public var homeDirectory: String
|
|
public var dataLoader: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
|
private static let log = CodexBarLog.logger(LogCategories.geminiProbe)
|
|
private static let quotaEndpoint = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota"
|
|
private static let loadCodeAssistEndpoint = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
|
|
private static let projectsEndpoint = "https://cloudresourcemanager.googleapis.com/v1/projects"
|
|
private static let credentialsPath = "/.gemini/oauth_creds.json"
|
|
private static let settingsPath = "/.gemini/settings.json"
|
|
private static let tokenRefreshEndpoint = "https://oauth2.googleapis.com/token"
|
|
|
|
public init(
|
|
timeout: TimeInterval = 10.0,
|
|
homeDirectory: String = NSHomeDirectory(),
|
|
dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = Self.defaultDataLoader)
|
|
{
|
|
self.timeout = timeout
|
|
self.homeDirectory = homeDirectory
|
|
self.dataLoader = dataLoader
|
|
}
|
|
|
|
/// Reads the current Gemini auth type from settings.json
|
|
public static func currentAuthType(homeDirectory: String = NSHomeDirectory()) -> GeminiAuthType {
|
|
let settingsURL = URL(fileURLWithPath: homeDirectory + Self.settingsPath)
|
|
|
|
guard let data = try? Data(contentsOf: settingsURL),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let security = json["security"] as? [String: Any],
|
|
let auth = security["auth"] as? [String: Any],
|
|
let selectedType = auth["selectedType"] as? String
|
|
else {
|
|
return .unknown
|
|
}
|
|
|
|
if selectedType == "api-key" {
|
|
return .apiKey
|
|
}
|
|
return GeminiAuthType(rawValue: selectedType) ?? .unknown
|
|
}
|
|
|
|
public func fetch() async throws -> GeminiStatusSnapshot {
|
|
// Block explicitly unsupported auth types; allow unknown to try OAuth creds
|
|
let authType = Self.currentAuthType(homeDirectory: self.homeDirectory)
|
|
switch authType {
|
|
case .apiKey:
|
|
throw GeminiStatusProbeError.unsupportedAuthType("API key")
|
|
case .vertexAI:
|
|
throw GeminiStatusProbeError.unsupportedAuthType("Vertex AI")
|
|
case .oauthPersonal, .unknown:
|
|
break
|
|
}
|
|
|
|
let snap = try await Self.fetchViaAPI(
|
|
timeout: self.timeout,
|
|
homeDirectory: self.homeDirectory,
|
|
dataLoader: self.dataLoader)
|
|
|
|
Self.log.info("Gemini API fetch ok", metadata: [
|
|
"dailyPercentLeft": "\(snap.dailyPercentLeft ?? -1)",
|
|
])
|
|
return snap
|
|
}
|
|
|
|
// MARK: - Direct API approach
|
|
|
|
private static func fetchViaAPI(
|
|
timeout: TimeInterval,
|
|
homeDirectory: String,
|
|
dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws
|
|
-> GeminiStatusSnapshot
|
|
{
|
|
let creds = try Self.loadCredentials(homeDirectory: homeDirectory)
|
|
|
|
let expiryStr = creds.expiryDate.map { "\($0)" } ?? "nil"
|
|
let hasRefresh = creds.refreshToken != nil
|
|
Self.log.debug("Token check", metadata: [
|
|
"expiry": expiryStr,
|
|
"hasRefresh": hasRefresh ? "1" : "0",
|
|
"now": "\(Date())",
|
|
])
|
|
|
|
var accessToken = creds.accessToken?.isEmpty == false ? creds.accessToken : nil
|
|
var idToken = creds.idToken
|
|
let needsRefresh = accessToken == nil || creds.expiryDate.map { $0 < Date() } == true
|
|
if needsRefresh {
|
|
if accessToken == nil {
|
|
Self.log.info("No access token found; attempting refresh from stored Gemini credentials")
|
|
} else if let expiry = creds.expiryDate {
|
|
Self.log.info("Token expired; attempting refresh", metadata: [
|
|
"expiry": "\(expiry)",
|
|
])
|
|
}
|
|
|
|
guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else {
|
|
Self.log.error("No refresh token available")
|
|
throw GeminiStatusProbeError.notLoggedIn
|
|
}
|
|
accessToken = try await Self.refreshAccessToken(
|
|
refreshToken: refreshToken,
|
|
timeout: timeout,
|
|
homeDirectory: homeDirectory,
|
|
dataLoader: dataLoader)
|
|
idToken = (try? Self.loadCredentials(homeDirectory: homeDirectory).idToken) ?? idToken
|
|
}
|
|
guard let accessToken else {
|
|
Self.log.error("No access token found")
|
|
throw GeminiStatusProbeError.notLoggedIn
|
|
}
|
|
|
|
// Extract account info from JWT
|
|
let claims = Self.extractClaimsFromToken(idToken)
|
|
|
|
// Load Code Assist status to get project ID and tier (aligned with CLI setupUser logic)
|
|
let caStatus = try await Self.loadCodeAssistStatus(
|
|
accessToken: accessToken,
|
|
timeout: timeout,
|
|
dataLoader: dataLoader)
|
|
|
|
// Determine the project ID to use for quota fetching.
|
|
// Priority:
|
|
// 1. Project ID returned by loadCodeAssist (e.g. managed project for free tier)
|
|
// 2. Discovered project ID from cloud resource manager (e.g. user's own project)
|
|
var projectId = caStatus.projectId
|
|
if projectId == nil {
|
|
projectId = try? await Self.discoverGeminiProjectId(
|
|
accessToken: accessToken,
|
|
timeout: timeout,
|
|
dataLoader: dataLoader)
|
|
}
|
|
|
|
guard let url = URL(string: Self.quotaEndpoint) else {
|
|
throw GeminiStatusProbeError.apiError("Invalid endpoint URL")
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
// Include project ID for accurate quota
|
|
if let projectId {
|
|
request.httpBody = Data("{\"project\": \"\(projectId)\"}".utf8)
|
|
} else {
|
|
request.httpBody = Data("{}".utf8)
|
|
}
|
|
request.timeoutInterval = timeout
|
|
|
|
let (data, response) = try await dataLoader(request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw GeminiStatusProbeError.apiError("Invalid response")
|
|
}
|
|
|
|
if httpResponse.statusCode == 401 {
|
|
try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data)
|
|
throw GeminiStatusProbeError.notLoggedIn
|
|
}
|
|
|
|
guard httpResponse.statusCode == 200 else {
|
|
try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data)
|
|
throw GeminiStatusProbeError.apiError("HTTP \(httpResponse.statusCode)")
|
|
}
|
|
|
|
let snapshot = try Self.parseAPIResponse(data, email: claims.email)
|
|
|
|
let plan = Self.resolveAccountPlan(
|
|
tier: caStatus.tier,
|
|
hostedDomain: claims.hostedDomain,
|
|
paidTierName: caStatus.paidTierName)
|
|
|
|
return GeminiStatusSnapshot(
|
|
modelQuotas: snapshot.modelQuotas,
|
|
rawText: snapshot.rawText,
|
|
accountEmail: snapshot.accountEmail,
|
|
accountPlan: plan)
|
|
}
|
|
|
|
private static func discoverGeminiProjectId(
|
|
accessToken: String,
|
|
timeout: TimeInterval,
|
|
dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws
|
|
-> String?
|
|
{
|
|
guard let url = URL(string: projectsEndpoint) else { return nil }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
request.timeoutInterval = timeout
|
|
|
|
let (data, response) = try await dataLoader(request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
|
return nil
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let projects = json["projects"] as? [[String: Any]]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
// Look for Gemini API project (has "generative-language" label or "gen-lang-client" prefix)
|
|
for project in projects {
|
|
guard let projectId = project["projectId"] as? String else { continue }
|
|
|
|
// Check for gen-lang-client prefix (Gemini CLI projects)
|
|
if projectId.hasPrefix("gen-lang-client") {
|
|
return projectId
|
|
}
|
|
|
|
// Check for generative-language label
|
|
if let labels = project["labels"] as? [String: String],
|
|
labels["generative-language"] != nil
|
|
{
|
|
return projectId
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private struct CodeAssistStatus {
|
|
let tier: GeminiUserTierId?
|
|
let projectId: String?
|
|
let paidTierName: String?
|
|
|
|
static let empty = CodeAssistStatus(tier: nil, projectId: nil, paidTierName: nil)
|
|
}
|
|
|
|
private static func loadCodeAssistStatus(
|
|
accessToken: String,
|
|
timeout: TimeInterval,
|
|
dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws
|
|
-> CodeAssistStatus
|
|
{
|
|
guard let url = URL(string: loadCodeAssistEndpoint) else {
|
|
self.log.warning("loadCodeAssist: invalid endpoint URL")
|
|
return .empty
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = Data("{\"metadata\":{\"ideType\":\"GEMINI_CLI\",\"pluginType\":\"GEMINI\"}}".utf8)
|
|
request.timeoutInterval = timeout
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await dataLoader(request)
|
|
} catch {
|
|
Self.log.warning("loadCodeAssist: request failed", metadata: ["error": "\(error)"])
|
|
return .empty
|
|
}
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
Self.log.warning("loadCodeAssist: invalid response type")
|
|
return .empty
|
|
}
|
|
|
|
guard httpResponse.statusCode == 200 else {
|
|
try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data)
|
|
Self.log.warning("loadCodeAssist: HTTP error", metadata: [
|
|
"statusCode": "\(httpResponse.statusCode)",
|
|
"body": String(data: data, encoding: .utf8) ?? "<binary>",
|
|
])
|
|
return .empty
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
Self.log.warning("loadCodeAssist: failed to parse JSON", metadata: [
|
|
"body": String(data: data, encoding: .utf8) ?? "<binary>",
|
|
])
|
|
return .empty
|
|
}
|
|
|
|
let rawProjectId: String? = {
|
|
if let project = json["cloudaicompanionProject"] as? String {
|
|
return project
|
|
}
|
|
if let project = json["cloudaicompanionProject"] as? [String: Any] {
|
|
if let projectId = project["id"] as? String {
|
|
return projectId
|
|
}
|
|
if let projectId = project["projectId"] as? String {
|
|
return projectId
|
|
}
|
|
}
|
|
return nil
|
|
}()
|
|
let trimmedProjectId = rawProjectId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let projectId = trimmedProjectId?.isEmpty == true ? nil : trimmedProjectId
|
|
if let projectId {
|
|
Self.log.info("loadCodeAssist: project detected", metadata: ["projectId": projectId])
|
|
}
|
|
|
|
let tierId = (json["currentTier"] as? [String: Any])?["id"] as? String
|
|
let paidTierName = Self.parsePaidTierName(from: json)
|
|
|
|
guard let tierId else {
|
|
Self.log.warning("loadCodeAssist: no currentTier.id in response", metadata: [
|
|
"json": "\(json)",
|
|
])
|
|
return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName)
|
|
}
|
|
|
|
guard let tier = GeminiUserTierId(rawValue: tierId) else {
|
|
Self.log.warning("loadCodeAssist: unknown tier ID", metadata: ["tierId": tierId])
|
|
return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName)
|
|
}
|
|
|
|
Self.log.info("loadCodeAssist: success", metadata: [
|
|
"tier": tierId,
|
|
"projectId": projectId ?? "nil",
|
|
"paidTierName": paidTierName ?? "nil",
|
|
])
|
|
return CodeAssistStatus(tier: tier, projectId: projectId, paidTierName: paidTierName)
|
|
}
|
|
|
|
private struct OAuthCredentials {
|
|
let accessToken: String?
|
|
let idToken: String?
|
|
let refreshToken: String?
|
|
let expiryDate: Date?
|
|
}
|
|
|
|
fileprivate struct OAuthClientCredentials {
|
|
let clientId: String
|
|
let clientSecret: String
|
|
}
|
|
|
|
private static func isLikelyFnmManagedPath(_ path: String) -> Bool {
|
|
let normalized = path.replacingOccurrences(of: "\\", with: "/")
|
|
return normalized.contains("/fnm_multishells/")
|
|
|| (normalized.contains("/node-versions/") && normalized.contains("/fnm/"))
|
|
}
|
|
|
|
private static func resolveExecutableOnEnvironmentPath(
|
|
named executable: String,
|
|
environment: [String: String]) -> String?
|
|
{
|
|
guard let path = environment["PATH"] else { return nil }
|
|
for directory in path.split(separator: ":") where !directory.isEmpty {
|
|
let candidate = URL(fileURLWithPath: String(directory), isDirectory: true)
|
|
.appendingPathComponent(executable)
|
|
.path
|
|
if FileManager.default.isExecutableFile(atPath: candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func resolveGeminiPackageRootViaFnm(
|
|
fnmPath: String,
|
|
environment: [String: String]) -> String?
|
|
{
|
|
guard let currentVersion = runProcess(
|
|
executable: fnmPath,
|
|
arguments: ["current"],
|
|
environment: environment,
|
|
timeout: 2.0),
|
|
!currentVersion.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
// Prefer npm root -g because require.resolve searches from the current
|
|
// working directory and often fails for globally-installed packages.
|
|
if let npmRoot = runProcess(
|
|
executable: fnmPath,
|
|
arguments: [
|
|
"exec",
|
|
"--using",
|
|
currentVersion,
|
|
"npm",
|
|
"root",
|
|
"-g",
|
|
],
|
|
environment: environment,
|
|
timeout: 4.0),
|
|
!npmRoot.isEmpty
|
|
{
|
|
let packageRoot = "\(npmRoot)/@google/gemini-cli"
|
|
let packageJSONPath = "\(packageRoot)/package.json"
|
|
if FileManager.default.fileExists(atPath: packageJSONPath) {
|
|
return packageRoot
|
|
}
|
|
}
|
|
|
|
// Fallback for non-npm global installations.
|
|
if let packageJSONPath = runProcess(
|
|
executable: fnmPath,
|
|
arguments: [
|
|
"exec",
|
|
"--using",
|
|
currentVersion,
|
|
"node",
|
|
"-p",
|
|
"require.resolve('@google/gemini-cli/package.json')",
|
|
],
|
|
environment: environment,
|
|
timeout: 4.0),
|
|
!packageJSONPath.isEmpty
|
|
{
|
|
return (packageJSONPath as NSString).deletingLastPathComponent
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func findGeminiPackageRoot(startingAt path: String) -> String? {
|
|
let fileManager = FileManager.default
|
|
var currentURL = URL(fileURLWithPath: path).standardizedFileURL
|
|
|
|
var isDirectory: ObjCBool = false
|
|
if !fileManager.fileExists(atPath: currentURL.path, isDirectory: &isDirectory) || !isDirectory.boolValue {
|
|
currentURL.deleteLastPathComponent()
|
|
}
|
|
|
|
// Bound the walk so an unrelated Gemini install elsewhere on the host
|
|
// (e.g. a global npm/brew install unrelated to the resolved binary) can't
|
|
// contaminate discovery started from the actual binary path.
|
|
let maxAscents = 8
|
|
for _ in 0...maxAscents {
|
|
let packageJSONURL = currentURL.appendingPathComponent("package.json")
|
|
if let data = try? Data(contentsOf: packageJSONURL),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
json["name"] as? String == "@google/gemini-cli"
|
|
{
|
|
return currentURL.path
|
|
}
|
|
|
|
// Also check for a global Node installation layout:
|
|
// <current>/lib/node_modules/@google/gemini-cli/package.json
|
|
let globalPackageJSONURL = currentURL
|
|
.appendingPathComponent("lib")
|
|
.appendingPathComponent("node_modules")
|
|
.appendingPathComponent("@google")
|
|
.appendingPathComponent("gemini-cli")
|
|
.appendingPathComponent("package.json")
|
|
if let data = try? Data(contentsOf: globalPackageJSONURL),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
json["name"] as? String == "@google/gemini-cli"
|
|
{
|
|
return globalPackageJSONURL.deletingLastPathComponent().path
|
|
}
|
|
|
|
// Homebrew layout:
|
|
// <cellar-version>/libexec/lib/node_modules/@google/gemini-cli/package.json
|
|
let homebrewPackageJSONURL = currentURL
|
|
.appendingPathComponent("libexec")
|
|
.appendingPathComponent("lib")
|
|
.appendingPathComponent("node_modules")
|
|
.appendingPathComponent("@google")
|
|
.appendingPathComponent("gemini-cli")
|
|
.appendingPathComponent("package.json")
|
|
if let data = try? Data(contentsOf: homebrewPackageJSONURL),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
json["name"] as? String == "@google/gemini-cli"
|
|
{
|
|
return homebrewPackageJSONURL.deletingLastPathComponent().path
|
|
}
|
|
|
|
let parentURL = currentURL.deletingLastPathComponent()
|
|
if parentURL.path == currentURL.path {
|
|
return nil
|
|
}
|
|
currentURL = parentURL
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func extractOAuthCredentials(fromGeminiPackageRoot packageRoot: String) -> OAuthClientCredentials? {
|
|
// Check the standard distributed file first, then any sibling core package
|
|
let oauthFile = "dist/src/code_assist/oauth2.js"
|
|
let candidatePaths = [
|
|
"\(packageRoot)/\(oauthFile)",
|
|
"\(packageRoot)/node_modules/@google/gemini-cli-core/\(oauthFile)",
|
|
]
|
|
|
|
for path in candidatePaths {
|
|
if let content = try? String(contentsOfFile: path, encoding: .utf8),
|
|
let credentials = Self.parseOAuthCredentials(from: content)
|
|
{
|
|
return credentials
|
|
}
|
|
}
|
|
|
|
return Self.extractOAuthCredentialsFromBundle(packageRoot: packageRoot)
|
|
}
|
|
|
|
private static func extractOAuthCredentialsFromBundle(packageRoot: String) -> OAuthClientCredentials? {
|
|
let bundleRoot = URL(fileURLWithPath: packageRoot).appendingPathComponent("bundle", isDirectory: true)
|
|
let entryURL = bundleRoot.appendingPathComponent("gemini.js")
|
|
|
|
guard FileManager.default.fileExists(atPath: entryURL.path) else {
|
|
return nil
|
|
}
|
|
|
|
var pendingURLs = [entryURL]
|
|
var visitedPaths = Set<String>()
|
|
|
|
while !pendingURLs.isEmpty {
|
|
let currentURL = pendingURLs.removeFirst()
|
|
let standardizedPath = currentURL.standardizedFileURL.path
|
|
guard visitedPaths.insert(standardizedPath).inserted,
|
|
let content = try? String(contentsOf: currentURL, encoding: .utf8)
|
|
else {
|
|
continue
|
|
}
|
|
|
|
if let credentials = Self.parseOAuthCredentials(from: content) {
|
|
return credentials
|
|
}
|
|
|
|
let imports = Self.extractRelativeJavaScriptImports(from: content)
|
|
for importPath in imports {
|
|
let nextURL = URL(fileURLWithPath: importPath, relativeTo: currentURL.deletingLastPathComponent())
|
|
.standardizedFileURL
|
|
guard nextURL.path.hasPrefix(bundleRoot.path) else { continue }
|
|
pendingURLs.append(nextURL)
|
|
}
|
|
}
|
|
|
|
guard let bundleFiles = try? FileManager.default.contentsOfDirectory(
|
|
at: bundleRoot,
|
|
includingPropertiesForKeys: nil,
|
|
options: [.skipsHiddenFiles])
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
for url in bundleFiles where url.pathExtension == "js" && !visitedPaths.contains(url.standardizedFileURL.path) {
|
|
guard let content = try? String(contentsOf: url, encoding: .utf8) else { continue }
|
|
if let credentials = Self.parseOAuthCredentials(from: content) {
|
|
return credentials
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func extractRelativeJavaScriptImports(from content: String) -> [String] {
|
|
let patterns = [
|
|
#"(?:import|export)\s+(?:[^;]*?\s+from\s+)?[\"'](\./[^\"']+\.js)[\"']"#,
|
|
#"import\(\s*[\"'](\./[^\"']+\.js)[\"']\s*\)"#,
|
|
]
|
|
|
|
var discoveredPaths: [String] = []
|
|
var seen = Set<String>()
|
|
let fullRange = NSRange(content.startIndex..., in: content)
|
|
|
|
for pattern in patterns {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
|
|
for match in regex.matches(in: content, range: fullRange) {
|
|
guard let range = Range(match.range(at: 1), in: content) else { continue }
|
|
let path = String(content[range])
|
|
if seen.insert(path).inserted {
|
|
discoveredPaths.append(path)
|
|
}
|
|
}
|
|
}
|
|
|
|
return discoveredPaths
|
|
}
|
|
|
|
private static func extractOAuthCredentialsFromLegacyPaths(realGeminiPath: String) -> OAuthClientCredentials? {
|
|
let binDir = (realGeminiPath as NSString).deletingLastPathComponent
|
|
let baseDir = (binDir as NSString).deletingLastPathComponent
|
|
|
|
let oauthSubpath =
|
|
"node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"
|
|
let nixShareSubpath =
|
|
"share/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"
|
|
let oauthFile = "dist/src/code_assist/oauth2.js"
|
|
let possiblePaths = [
|
|
// Homebrew nested structure
|
|
"\(baseDir)/libexec/lib/\(oauthSubpath)",
|
|
"\(baseDir)/lib/\(oauthSubpath)",
|
|
// Nix package layout
|
|
"\(baseDir)/\(nixShareSubpath)",
|
|
// Bun/npm sibling structure: gemini-cli-core is a sibling to gemini-cli
|
|
"\(baseDir)/../gemini-cli-core/\(oauthFile)",
|
|
// npm nested inside gemini-cli
|
|
"\(baseDir)/node_modules/@google/gemini-cli-core/\(oauthFile)",
|
|
]
|
|
|
|
for path in possiblePaths {
|
|
if let content = try? String(contentsOfFile: path, encoding: .utf8),
|
|
let credentials = Self.parseOAuthCredentials(from: content)
|
|
{
|
|
return credentials
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func parseOAuthCredentials(from content: String) -> OAuthClientCredentials? {
|
|
// Match: const/let/var OAUTH_CLIENT_ID = '...';
|
|
let clientIdPattern = #"(?:const|let|var)?\s*OAUTH_CLIENT_ID\s*=\s*['"]([\w\-\.]+)['"]\s*;"#
|
|
let secretPattern = #"(?:const|let|var)?\s*OAUTH_CLIENT_SECRET\s*=\s*['"]([\w\-]+)['"]\s*;"#
|
|
|
|
guard let clientIdRegex = try? NSRegularExpression(pattern: clientIdPattern),
|
|
let secretRegex = try? NSRegularExpression(pattern: secretPattern)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let range = NSRange(content.startIndex..., in: content)
|
|
|
|
guard let clientIdMatch = clientIdRegex.firstMatch(in: content, range: range),
|
|
let clientIdRange = Range(clientIdMatch.range(at: 1), in: content),
|
|
let secretMatch = secretRegex.firstMatch(in: content, range: range),
|
|
let secretRange = Range(secretMatch.range(at: 1), in: content)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let clientId = String(content[clientIdRange])
|
|
let clientSecret = String(content[secretRange])
|
|
|
|
return OAuthClientCredentials(clientId: clientId, clientSecret: clientSecret)
|
|
}
|
|
|
|
private static func refreshAccessToken(
|
|
refreshToken: String,
|
|
timeout: TimeInterval,
|
|
homeDirectory: String,
|
|
dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws
|
|
-> String
|
|
{
|
|
guard let url = URL(string: tokenRefreshEndpoint) else {
|
|
throw GeminiStatusProbeError.apiError("Invalid token refresh URL")
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
request.timeoutInterval = timeout
|
|
|
|
guard let oauthCreds = Self.extractOAuthCredentials() else {
|
|
Self.log.error("Could not extract OAuth credentials from Gemini CLI")
|
|
throw GeminiStatusProbeError.apiError(GeminiConsumerTierMigration.oauthRecoveryError)
|
|
}
|
|
|
|
let body = [
|
|
"client_id=\(oauthCreds.clientId)",
|
|
"client_secret=\(oauthCreds.clientSecret)",
|
|
"refresh_token=\(refreshToken)",
|
|
"grant_type=refresh_token",
|
|
].joined(separator: "&")
|
|
request.httpBody = body.data(using: .utf8)
|
|
|
|
let (data, response) = try await dataLoader(request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw GeminiStatusProbeError.apiError("Invalid refresh response")
|
|
}
|
|
|
|
guard httpResponse.statusCode == 200 else {
|
|
try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data)
|
|
Self.log.error("Token refresh failed", metadata: [
|
|
"statusCode": "\(httpResponse.statusCode)",
|
|
])
|
|
throw GeminiStatusProbeError.notLoggedIn
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let newAccessToken = json["access_token"] as? String
|
|
else {
|
|
throw GeminiStatusProbeError.parseFailed("Could not parse refresh response")
|
|
}
|
|
|
|
// Update stored credentials with new token
|
|
try Self.updateStoredCredentials(json, homeDirectory: homeDirectory)
|
|
|
|
Self.log.info("Token refreshed successfully")
|
|
return newAccessToken
|
|
}
|
|
|
|
private static func updateStoredCredentials(_ refreshResponse: [String: Any], homeDirectory: String) throws {
|
|
let credsURL = URL(fileURLWithPath: homeDirectory + Self.credentialsPath)
|
|
|
|
guard let existingCreds = try? Data(contentsOf: credsURL),
|
|
var json = try? JSONSerialization.jsonObject(with: existingCreds) as? [String: Any]
|
|
else {
|
|
return
|
|
}
|
|
|
|
// Update with new values from refresh response
|
|
if let accessToken = refreshResponse["access_token"] {
|
|
json["access_token"] = accessToken
|
|
}
|
|
if let expiresIn = refreshResponse["expires_in"] as? Double {
|
|
json["expiry_date"] = (Date().timeIntervalSince1970 + expiresIn) * 1000
|
|
}
|
|
if let idToken = refreshResponse["id_token"] {
|
|
json["id_token"] = idToken
|
|
}
|
|
|
|
let updatedData = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted])
|
|
try updatedData.write(to: credsURL, options: .atomic)
|
|
}
|
|
|
|
private static func loadCredentials(homeDirectory: String) throws -> OAuthCredentials {
|
|
let credsURL = URL(fileURLWithPath: homeDirectory + Self.credentialsPath)
|
|
|
|
guard FileManager.default.fileExists(atPath: credsURL.path) else {
|
|
throw GeminiStatusProbeError.notLoggedIn
|
|
}
|
|
|
|
let data = try Data(contentsOf: credsURL)
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw GeminiStatusProbeError.parseFailed("Invalid credentials file")
|
|
}
|
|
|
|
let accessToken = json["access_token"] as? String
|
|
let idToken = json["id_token"] as? String
|
|
let refreshToken = json["refresh_token"] as? String
|
|
|
|
var expiryDate: Date?
|
|
if let expiryMs = json["expiry_date"] as? Double {
|
|
expiryDate = Date(timeIntervalSince1970: expiryMs / 1000)
|
|
}
|
|
|
|
return OAuthCredentials(
|
|
accessToken: accessToken,
|
|
idToken: idToken,
|
|
refreshToken: refreshToken,
|
|
expiryDate: expiryDate)
|
|
}
|
|
|
|
private struct TokenClaims {
|
|
let email: String?
|
|
let hostedDomain: String?
|
|
}
|
|
|
|
private static func extractClaimsFromToken(_ idToken: String?) -> TokenClaims {
|
|
guard let token = idToken else { return TokenClaims(email: nil, hostedDomain: nil) }
|
|
|
|
let parts = token.components(separatedBy: ".")
|
|
guard parts.count >= 2 else { return TokenClaims(email: nil, hostedDomain: nil) }
|
|
|
|
var payload = parts[1]
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
|
|
let remainder = payload.count % 4
|
|
if remainder > 0 {
|
|
payload += String(repeating: "=", count: 4 - remainder)
|
|
}
|
|
|
|
guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else {
|
|
return TokenClaims(email: nil, hostedDomain: nil)
|
|
}
|
|
|
|
return TokenClaims(
|
|
email: json["email"] as? String,
|
|
hostedDomain: json["hd"] as? String)
|
|
}
|
|
|
|
private static func extractEmailFromToken(_ idToken: String?) -> String? {
|
|
self.extractClaimsFromToken(idToken).email
|
|
}
|
|
|
|
private struct QuotaBucket: Decodable {
|
|
let remainingFraction: Double?
|
|
let resetTime: String?
|
|
let modelId: String?
|
|
let tokenType: String?
|
|
}
|
|
|
|
private struct QuotaResponse: Decodable {
|
|
let buckets: [QuotaBucket]?
|
|
}
|
|
|
|
private static func parseAPIResponse(_ data: Data, email: String?) throws -> GeminiStatusSnapshot {
|
|
let decoder = JSONDecoder()
|
|
let response = try decoder.decode(QuotaResponse.self, from: data)
|
|
|
|
guard let buckets = response.buckets, !buckets.isEmpty else {
|
|
throw GeminiStatusProbeError.parseFailed("No quota buckets in response")
|
|
}
|
|
|
|
// Group quotas by model, keeping lowest per model (input tokens usually)
|
|
var modelQuotaMap: [String: (fraction: Double, resetString: String?)] = [:]
|
|
|
|
for bucket in buckets {
|
|
guard let modelId = bucket.modelId, let fraction = bucket.remainingFraction else { continue }
|
|
|
|
if let existing = modelQuotaMap[modelId] {
|
|
if fraction < existing.fraction {
|
|
modelQuotaMap[modelId] = (fraction, bucket.resetTime)
|
|
}
|
|
} else {
|
|
modelQuotaMap[modelId] = (fraction, bucket.resetTime)
|
|
}
|
|
}
|
|
|
|
// Convert to sorted array (by model name for consistent ordering)
|
|
let quotas = modelQuotaMap
|
|
.sorted { $0.key < $1.key }
|
|
.map { modelId, info in
|
|
let resetDate = info.resetString.flatMap { Self.parseResetTime($0) }
|
|
return GeminiModelQuota(
|
|
modelId: modelId,
|
|
percentLeft: info.fraction * 100,
|
|
resetTime: resetDate,
|
|
resetDescription: info.resetString.flatMap { Self.formatResetTime($0) })
|
|
}
|
|
|
|
let rawText = String(data: data, encoding: .utf8) ?? ""
|
|
|
|
return GeminiStatusSnapshot(
|
|
modelQuotas: quotas,
|
|
rawText: rawText,
|
|
accountEmail: email,
|
|
accountPlan: nil)
|
|
}
|
|
|
|
private static func parseResetTime(_ isoString: String) -> Date? {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
|
if let date = formatter.date(from: isoString) {
|
|
return date
|
|
}
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: isoString)
|
|
}
|
|
|
|
private static func formatResetTime(_ isoString: String) -> String {
|
|
guard let resetDate = parseResetTime(isoString) else {
|
|
return "Resets soon"
|
|
}
|
|
|
|
let now = Date()
|
|
let interval = resetDate.timeIntervalSince(now)
|
|
|
|
if interval <= 0 {
|
|
return "Resets soon"
|
|
}
|
|
|
|
let hours = Int(interval / 3600)
|
|
let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60)
|
|
|
|
if hours > 0 {
|
|
return "Resets in \(hours)h \(minutes)m"
|
|
} else {
|
|
return "Resets in \(minutes)m"
|
|
}
|
|
}
|
|
|
|
// MARK: - Legacy CLI parsing (kept for fallback)
|
|
|
|
public static func parse(text: String) throws -> GeminiStatusSnapshot {
|
|
let clean = TextParsing.stripANSICodes(text)
|
|
guard !clean.isEmpty else { throw GeminiStatusProbeError.timedOut }
|
|
|
|
let quotas = Self.parseModelUsageTable(clean)
|
|
|
|
if quotas.isEmpty {
|
|
if clean.contains("Login with Google") || clean.contains("Use Gemini API key") {
|
|
throw GeminiStatusProbeError.notLoggedIn
|
|
}
|
|
if clean.contains("Waiting for auth"), !clean.contains("Usage") {
|
|
throw GeminiStatusProbeError.notLoggedIn
|
|
}
|
|
throw GeminiStatusProbeError.parseFailed("No usage data found in /stats output")
|
|
}
|
|
|
|
return GeminiStatusSnapshot(
|
|
modelQuotas: quotas,
|
|
rawText: text,
|
|
accountEmail: nil,
|
|
accountPlan: nil)
|
|
}
|
|
|
|
private static func parseModelUsageTable(_ text: String) -> [GeminiModelQuota] {
|
|
let lines = text.components(separatedBy: .newlines)
|
|
var quotas: [GeminiModelQuota] = []
|
|
|
|
let pattern = #"(gemini[-\w.]+)\s+[\d-]+\s+([0-9]+(?:\.[0-9]+)?)\s*%\s*\(([^)]+)\)"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
|
return []
|
|
}
|
|
|
|
for line in lines {
|
|
let cleanLine = line.replacingOccurrences(of: "│", with: " ")
|
|
let range = NSRange(cleanLine.startIndex..<cleanLine.endIndex, in: cleanLine)
|
|
guard let match = regex.firstMatch(in: cleanLine, options: [], range: range),
|
|
match.numberOfRanges >= 4 else { continue }
|
|
|
|
guard let modelRange = Range(match.range(at: 1), in: cleanLine),
|
|
let pctRange = Range(match.range(at: 2), in: cleanLine),
|
|
let pct = Double(cleanLine[pctRange])
|
|
else { continue }
|
|
|
|
let modelId = String(cleanLine[modelRange])
|
|
var resetDesc: String?
|
|
if let resetRange = Range(match.range(at: 3), in: cleanLine) {
|
|
resetDesc = String(cleanLine[resetRange]).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
|
|
quotas.append(GeminiModelQuota(
|
|
modelId: modelId,
|
|
percentLeft: pct,
|
|
resetTime: nil,
|
|
resetDescription: resetDesc))
|
|
}
|
|
|
|
return quotas
|
|
}
|
|
}
|
|
|
|
extension GeminiStatusProbe {
|
|
fileprivate static func extractOAuthCredentials() -> OAuthClientCredentials? {
|
|
if let resolved = GeminiOAuthConfig.environmentClient() {
|
|
return OAuthClientCredentials(clientId: resolved.clientID, clientSecret: resolved.clientSecret)
|
|
}
|
|
if let path = GeminiOAuthConfig.configuredOAuth2JSPath,
|
|
let credentials = Self.parseOAuthCredentials(fromFile: path)
|
|
{
|
|
return credentials
|
|
}
|
|
if let credentials = Self.discoverOAuthCredentialsFromInstalledCLI() {
|
|
return OAuthClientCredentials(clientId: credentials.clientID, clientSecret: credentials.clientSecret)
|
|
}
|
|
if let credentials = Self.discoverOAuthCredentialsFromKnownInstallPaths() {
|
|
return credentials
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Optional Homebrew/npm prefixes for last-resort discovery when the gemini
|
|
/// binary cannot be resolved (GUI PATH / sandbox). Tests inject fake Cellar trees.
|
|
@TaskLocal public static var knownInstallPrefixesForTesting: [String]?
|
|
|
|
fileprivate static func discoverOAuthCredentialsFromKnownInstallPaths() -> OAuthClientCredentials? {
|
|
let oauthFile = "dist/src/code_assist/oauth2.js"
|
|
let nestedOAuthFile =
|
|
"node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/\(oauthFile)"
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
|
|
// When tests inject Homebrew prefixes, skip the hard-coded host paths so
|
|
// fixture Cellar trees are not shadowed by the developer's real install.
|
|
if Self.knownInstallPrefixesForTesting == nil {
|
|
let possiblePaths = [
|
|
"/opt/homebrew/lib/node_modules/@google/gemini-cli-core/\(oauthFile)",
|
|
"/opt/homebrew/lib/\(nestedOAuthFile)",
|
|
"/usr/local/lib/node_modules/@google/gemini-cli-core/\(oauthFile)",
|
|
"/usr/local/lib/\(nestedOAuthFile)",
|
|
"\(home)/.npm-global/lib/node_modules/@google/gemini-cli-core/\(oauthFile)",
|
|
"\(home)/.npm-global/lib/\(nestedOAuthFile)",
|
|
]
|
|
|
|
for path in possiblePaths {
|
|
if let credentials = Self.parseOAuthCredentials(fromFile: path) {
|
|
return credentials
|
|
}
|
|
}
|
|
}
|
|
|
|
// Homebrew Cellar/opt libexec layouts keep credentials inside the package
|
|
// bundle when GUI PATH cannot resolve `/opt/homebrew/bin/gemini`.
|
|
for packageRoot in Self.knownHomebrewGeminiPackageRoots() {
|
|
if let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) {
|
|
return credentials
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
fileprivate static func knownHomebrewPrefixes() -> [String] {
|
|
if let overrides = self.knownInstallPrefixesForTesting, !overrides.isEmpty {
|
|
return overrides
|
|
}
|
|
return ["/opt/homebrew", "/usr/local"]
|
|
}
|
|
|
|
fileprivate static func knownHomebrewGeminiPackageRoots(
|
|
fileManager: FileManager = .default) -> [String]
|
|
{
|
|
var roots: [String] = []
|
|
var seen = Set<String>()
|
|
|
|
func appendPackageRoot(under prefixRoot: String) {
|
|
let packageRoot = URL(fileURLWithPath: prefixRoot)
|
|
.appendingPathComponent("libexec")
|
|
.appendingPathComponent("lib")
|
|
.appendingPathComponent("node_modules")
|
|
.appendingPathComponent("@google")
|
|
.appendingPathComponent("gemini-cli")
|
|
.path
|
|
guard fileManager.fileExists(atPath: packageRoot),
|
|
seen.insert(packageRoot).inserted
|
|
else {
|
|
return
|
|
}
|
|
roots.append(packageRoot)
|
|
}
|
|
|
|
for prefix in Self.knownHomebrewPrefixes() {
|
|
let optRoot = "\(prefix)/opt/gemini-cli"
|
|
if fileManager.fileExists(atPath: optRoot) {
|
|
appendPackageRoot(under: optRoot)
|
|
}
|
|
|
|
let cellarRoot = "\(prefix)/Cellar/gemini-cli"
|
|
guard let versions = try? fileManager.contentsOfDirectory(atPath: cellarRoot) else {
|
|
continue
|
|
}
|
|
for version in versions.sorted() {
|
|
appendPackageRoot(under: "\(cellarRoot)/\(version)")
|
|
}
|
|
}
|
|
|
|
return roots
|
|
}
|
|
|
|
fileprivate static func parseOAuthCredentials(fromFile path: String) -> OAuthClientCredentials? {
|
|
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return Self.parseOAuthCredentials(from: content)
|
|
}
|
|
|
|
fileprivate static func discoverOAuthCredentialsFromInstalledCLI() -> GeminiOAuthConfig.ClientCredentials? {
|
|
let env = ProcessInfo.processInfo.environment
|
|
|
|
guard let geminiPath = BinaryLocator.resolveGeminiBinary(
|
|
env: env,
|
|
loginPATH: LoginShellPathCache.shared.current)
|
|
?? TTYCommandRunner.which("gemini")
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let resolvedGeminiPath = URL(fileURLWithPath: geminiPath).resolvingSymlinksInPath().path
|
|
|
|
if let credentials = Self.extractOAuthCredentialsFromLegacyPaths(realGeminiPath: resolvedGeminiPath) {
|
|
return GeminiOAuthConfig.ClientCredentials(
|
|
clientID: credentials.clientId,
|
|
clientSecret: credentials.clientSecret)
|
|
}
|
|
|
|
if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath),
|
|
let fnmPath = Self.resolveExecutableOnEnvironmentPath(named: "fnm", environment: env)
|
|
?? TTYCommandRunner.which("fnm"),
|
|
let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env),
|
|
let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot)
|
|
{
|
|
return GeminiOAuthConfig.ClientCredentials(
|
|
clientID: credentials.clientId,
|
|
clientSecret: credentials.clientSecret)
|
|
}
|
|
|
|
if let packageRoot = Self.findGeminiPackageRoot(startingAt: resolvedGeminiPath),
|
|
let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot)
|
|
{
|
|
return GeminiOAuthConfig.ClientCredentials(
|
|
clientID: credentials.clientId,
|
|
clientSecret: credentials.clientSecret)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Plan display strings with tier mapping:
|
|
/// - paidTier.name: Most-specific paid subscription label from Google, regardless of currentTier
|
|
/// - standard-tier: Paid subscription fallback (Code Assist Standard/Enterprise, Developer Program Premium)
|
|
/// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025)
|
|
/// - free-tier: Personal free account
|
|
/// - legacy-tier: Unknown legacy/grandfathered tier
|
|
/// - nil (API failed): Leave blank (no display)
|
|
fileprivate static func resolveAccountPlan(
|
|
tier: GeminiUserTierId?,
|
|
hostedDomain: String?,
|
|
paidTierName: String?) -> String?
|
|
{
|
|
// Match Gemini CLI's contract: a named paid tier is the most specific plan signal,
|
|
// even when currentTier is missing, unknown, or still reports free-tier.
|
|
if let paidTierName {
|
|
self.log.info("Paid tier detected", metadata: [
|
|
"tier": tier?.rawValue ?? "unknown",
|
|
"plan": paidTierName,
|
|
])
|
|
return paidTierName
|
|
}
|
|
|
|
switch (tier, hostedDomain) {
|
|
case (.standard, _):
|
|
return "Paid"
|
|
case let (.free, .some(domain)):
|
|
Self.log.info("Workspace account detected", metadata: ["domain": domain])
|
|
return "Workspace"
|
|
case (.free, .none):
|
|
Self.log.info("Personal free account")
|
|
return "Free"
|
|
case (.legacy, _):
|
|
return "Legacy"
|
|
case (.none, _):
|
|
self.log.info("Tier detection failed, leaving plan blank")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func parsePaidTierName(from json: [String: Any]) -> String? {
|
|
guard let paidTier = json["paidTier"] as? [String: Any],
|
|
let rawName = paidTier["name"] as? String
|
|
else {
|
|
return nil
|
|
}
|
|
let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
extension GeminiStatusProbe {
|
|
private static let processTimeoutQueue = DispatchQueue(
|
|
label: "com.steipete.codexbar.gemini-process-timeout",
|
|
qos: .utility,
|
|
attributes: .concurrent)
|
|
|
|
package static func runProcess(
|
|
executable: String,
|
|
arguments: [String],
|
|
environment: [String: String],
|
|
timeout: TimeInterval) -> String?
|
|
{
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: executable)
|
|
process.arguments = arguments
|
|
|
|
var mergedEnvironment = environment
|
|
mergedEnvironment["PATH"] = PathBuilder.effectivePATH(
|
|
purposes: [.tty, .nodeTooling],
|
|
env: environment,
|
|
loginPATH: LoginShellPathCache.shared.current)
|
|
process.environment = mergedEnvironment
|
|
|
|
let stdout = Pipe()
|
|
let stderr = Pipe()
|
|
process.standardOutput = stdout
|
|
process.standardError = stderr
|
|
process.standardInput = nil
|
|
|
|
let stdoutCapture = ProcessPipeCapture(pipe: stdout)
|
|
let stderrCapture = ProcessPipeCapture(pipe: stderr)
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
stdoutCapture.stop()
|
|
stdout.fileHandleForWriting.closeFile()
|
|
stderrCapture.stop()
|
|
stderr.fileHandleForWriting.closeFile()
|
|
return nil
|
|
}
|
|
// Process has duplicated these descriptors. Closing the parent's copies lets readers observe EOF as
|
|
// soon as the helper exits instead of spending the full drain grace period on every successful call.
|
|
stdout.fileHandleForWriting.closeFile()
|
|
stderr.fileHandleForWriting.closeFile()
|
|
stdoutCapture.start()
|
|
stderrCapture.start()
|
|
let pid = process.processIdentifier
|
|
let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil
|
|
|
|
// Wait synchronously for Foundation to reap the helper. A separate timer owns timeout termination,
|
|
// so neither termination-handler scheduling nor stale Process.isRunning state controls normal exits.
|
|
let timeoutState = GeminiProcessTimeoutState()
|
|
let timeoutTimer = DispatchSource.makeTimerSource(queue: Self.processTimeoutQueue)
|
|
timeoutTimer.schedule(deadline: .now() + max(0, timeout))
|
|
timeoutTimer.setEventHandler {
|
|
guard process.isRunning else { return }
|
|
timeoutState.markTimedOut()
|
|
SubprocessRunner.terminateProcess(process, processGroup: processGroup)
|
|
}
|
|
timeoutTimer.resume()
|
|
process.waitUntilExit()
|
|
timeoutTimer.cancel()
|
|
|
|
if timeoutState.didTimeOut {
|
|
stdoutCapture.stop()
|
|
stderrCapture.stop()
|
|
return nil
|
|
}
|
|
|
|
let data = stdoutCapture.finishFirstLineSynchronously(timeout: 1)
|
|
stderrCapture.stop()
|
|
let output = ProcessPipeCapture.decodeUTF8(data)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard process.terminationStatus == 0, !output.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
return output.components(separatedBy: .newlines).first?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
|
|
private final class GeminiProcessTimeoutState: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var timedOut = false
|
|
|
|
var didTimeOut: Bool {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
return self.timedOut
|
|
}
|
|
|
|
func markTimedOut() {
|
|
self.lock.lock()
|
|
self.timedOut = true
|
|
self.lock.unlock()
|
|
}
|
|
}
|