Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Sources/ContextPanelCore/AccountConfigurationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ public enum AccountConnectorFactory {
case .missing, .partial:
configuredMetadataValue = nil
}
let cachedMetadataValue = geminiCachedMetadata(accountID: account.id, credentialStore: credentialStore)
let discoveredMetadata = GeminiOAuthClientMetadataDiscovery.discover(
environment: environment,
commandPath: account.commandPath,
Expand All @@ -267,7 +268,7 @@ public enum AccountConnectorFactory {
fileExists: geminiMetadataFileExists,
directoryLister: geminiMetadataDirectoryLister
)
guard let metadata = configuredMetadataValue ?? discoveredMetadata else {
guard let metadata = configuredMetadataValue ?? cachedMetadataValue ?? discoveredMetadata else {
let expanded = NSString(string: authPath).expandingTildeInPath
return FailingProviderConnector(
provider: .google,
Expand Down Expand Up @@ -342,6 +343,16 @@ public enum AccountConnectorFactory {
}
}

private static func geminiCachedMetadata(
accountID: String,
credentialStore: (any ProviderCredentialLoading)?
) -> GeminiOAuthClientMetadata? {
guard let credentialStore else { return nil }
let metadataAccountID = GeminiOAuthClientMetadata.credentialAccountID(for: accountID)
guard let data = try? credentialStore.load(accountID: metadataAccountID) else { return nil }
return try? JSONDecoder().decode(GeminiOAuthClientMetadata.self, from: data)
}

private static func geminiMetadata(
account: LocalProviderAccountConfiguration,
environment: [String: String]
Expand Down
49 changes: 48 additions & 1 deletion Sources/ContextPanelCore/GeminiCodeAssistQuota.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,29 @@ public struct GeminiAccountConfiguration: Equatable, Sendable {
}
}

public struct GeminiOAuthClientMetadata: Equatable, Sendable {
public struct GeminiOAuthClientMetadata: Codable, Equatable, Sendable {
public let clientID: String
public let clientSecret: String

public init(clientID: String, clientSecret: String) {
self.clientID = clientID
self.clientSecret = clientSecret
}

public static func credentialAccountID(for accountID: String) -> String {
"\(accountID).gemini-oauth-client-metadata"
}
}

public enum GeminiOAuthClientMetadataDiscoveryError: LocalizedError, Equatable {
case notFound

public var errorDescription: String? {
switch self {
case .notFound:
return "Gemini OAuth client metadata was not found in the selected CLI bundle."
}
}
}

public enum GeminiOAuthClientMetadataDiscovery {
Expand Down Expand Up @@ -165,6 +180,26 @@ public enum GeminiOAuthClientMetadataDiscovery {
return nil
}

public static func discover(
fromUserSelectedURL url: URL,
fileLoader: @escaping @Sendable (String) throws -> String = { path in
try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8)
},
directoryLister: @escaping @Sendable (String) -> [String] = { path in
let expanded = NSString(string: path).expandingTildeInPath
return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? []
}
) throws -> GeminiOAuthClientMetadata {
for path in userSelectedBundlePaths(url: url, directoryLister: directoryLister) {
guard
let source = try? fileLoader(path),
let metadata = parseClientMetadata(from: source)
else { continue }
return metadata
}
throw GeminiOAuthClientMetadataDiscoveryError.notFound
}

static func parseClientMetadata(from source: String) -> GeminiOAuthClientMetadata? {
guard
let clientID = stringLiteral(named: "OAUTH_CLIENT_ID", in: source),
Expand All @@ -185,6 +220,18 @@ public enum GeminiOAuthClientMetadataDiscovery {
return String(source[valueRange])
}

private static func userSelectedBundlePaths(
url: URL,
directoryLister: @Sendable (String) -> [String]
) -> [String] {
let path = url.path
if url.hasDirectoryPath {
return bundleChunkPaths(root: path, directoryLister: directoryLister)
}
let directory = url.deletingLastPathComponent().path
return orderedUnique([path] + bundleChunkPaths(root: directory, directoryLister: directoryLister))
}

private static func candidateBundlePaths(
environment: [String: String],
commandPath: String?,
Expand Down
63 changes: 63 additions & 0 deletions Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ struct SettingsPane: View {
.foregroundStyle(account.isEnabled ? CPTheme.statusColor(.healthy) : CPTheme.tertiaryText)
}
}
if account.connectorKind == .geminiCodeAssist, account.isEnabled, !model.hasGeminiMetadata(for: account) {
HStack(spacing: 8) {
Text("Gemini CLI access needed")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(CPTheme.statusColor(.stale))
Button("Select CLI Bundle") { model.authorizeGeminiMetadata(for: account) }
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Text(model.detailText(for: account))
.font(.system(size: 11))
.foregroundStyle(CPTheme.secondaryText)
Expand Down Expand Up @@ -401,6 +411,7 @@ final class SettingsPaneModel: ObservableObject {
@Published private(set) var authorizedPaths: Set<String> = []
@Published private(set) var missingAuthPaths: Set<String> = []
@Published private(set) var legacyAuthPaths: Set<String> = []
@Published private(set) var geminiMetadataAccountIDs: Set<String> = []
@Published var isClaudeOAuthCodeSheetPresented = false
@Published private(set) var isCompletingClaudeOAuth = false

Expand Down Expand Up @@ -472,6 +483,9 @@ final class SettingsPaneModel: ObservableObject {
})
loadedLegacyPaths.subtract(recentlyVerifiedAuthPaths)
legacyAuthPaths = loadedLegacyPaths
geminiMetadataAccountIDs = Set(accounts.compactMap { account in
account.connectorKind == .geminiCodeAssist && hasGeminiMetadata(for: account) ? account.id : nil
})
widgetPreferences = widgetPreferenceStores.load()
backgroundRefreshSettings = backgroundRefreshSettingsStore.load()
var primerSettings = resetPrimerSettingsStore.load()
Expand Down Expand Up @@ -819,6 +833,55 @@ final class SettingsPaneModel: ObservableObject {
(try? credentialStore.load(accountID: account.id)) != nil
}

func hasGeminiMetadata(for account: LocalProviderAccountConfiguration) -> Bool {
guard account.connectorKind == .geminiCodeAssist else { return true }
if geminiMetadataAccountIDs.contains(account.id) { return true }
let metadataAccountID = GeminiOAuthClientMetadata.credentialAccountID(for: account.id)
return (try? credentialStore.load(accountID: metadataAccountID)) != nil
}

func authorizeGeminiMetadata(for account: LocalProviderAccountConfiguration) {
guard account.connectorKind == .geminiCodeAssist else { return }
let panel = NSOpenPanel()
panel.message = "Select the Gemini CLI bundle folder or a JavaScript file inside it."
panel.prompt = "Select Bundle"
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = defaultGeminiBundleURL()
if #available(macOS 11.0, *) {
panel.allowedContentTypes = [.folder, .init(filenameExtension: "js")].compactMap { $0 }
} else {
panel.allowedFileTypes = ["js"]
}

panel.begin { [weak self] response in
guard let self, response == .OK, let url = panel.url else { return }
do {
let metadata = try GeminiOAuthClientMetadataDiscovery.discover(fromUserSelectedURL: url)
let data = try JSONEncoder().encode(metadata)
try credentialStore.save(
data,
accountID: GeminiOAuthClientMetadata.credentialAccountID(for: account.id)
)
geminiMetadataAccountIDs.insert(account.id)
errorMessage = nil
WidgetCenter.shared.reloadAllTimelines()
} catch {
errorMessage = error.localizedDescription
}
}
}

private func defaultGeminiBundleURL() -> URL? {
let paths = [
"/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle",
"/usr/local/lib/node_modules/@google/gemini-cli/bundle",
]
return paths.first(where: { FileManager.default.fileExists(atPath: $0) })
.map(URL.init(fileURLWithPath:))
}

private static func exchangeClaudeOAuthCode(
authorizationCode: ClaudeOAuthAuthorizationCode,
flow: PendingClaudeOAuth
Expand Down
28 changes: 28 additions & 0 deletions Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,34 @@ import Testing
#expect(connectors[0].provider == .google)
}

@Test func accountConnectorFactoryUsesCachedGeminiMetadataInSandbox() {
let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [
LocalProviderAccountConfiguration(
id: "gemini",
provider: .google,
connectorKind: .geminiCodeAssist,
displayName: "Gemini",
authPath: "/tmp/gemini.json"
)
])
let metadata = GeminiOAuthClientMetadata(clientID: "cached-client", clientSecret: "cached-secret")
let credentialStore = InMemoryProviderCredentialStore(storage: [
GeminiOAuthClientMetadata.credentialAccountID(for: "gemini"): try! JSONEncoder().encode(metadata),
])

let connectors = AccountConnectorFactory.connectors(
from: document,
credentialStore: credentialStore,
requiresBookmarkedAuthFiles: true,
environment: [:],
useBundledGeminiMetadataFallback: false,
geminiMetadataFileExists: { _ in false }
)

#expect(connectors.count == 1)
#expect(connectors[0].provider == .google)
}

@Test func sandboxedAuthLoaderRequiresSecurityScopedBookmark() async {
let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [
LocalProviderAccountConfiguration(
Expand Down
29 changes: 29 additions & 0 deletions Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,32 @@ import Testing

#expect(metadata == nil)
}

@Test func geminiOAuthClientMetadataDiscoveryParsesUserSelectedBundleDirectory() throws {
let source = #"""
var OAUTH_CLIENT_ID = "selected-client";
var OAUTH_CLIENT_SECRET = "selected-secret";
"""#

let metadata = try GeminiOAuthClientMetadataDiscovery.discover(
fromUserSelectedURL: URL(fileURLWithPath: "/Users/test/gemini-cli/bundle", isDirectory: true),
fileLoader: { path in
path.hasSuffix("metadata.js") ? source : ""
},
directoryLister: { root in
root == "/Users/test/gemini-cli/bundle" ? ["\(root)/metadata.js"] : []
}
)

#expect(metadata == GeminiOAuthClientMetadata(clientID: "selected-client", clientSecret: "selected-secret"))
}

@Test func geminiOAuthClientMetadataDiscoveryReportsMissingUserSelectedMetadata() {
#expect(throws: GeminiOAuthClientMetadataDiscoveryError.notFound) {
try GeminiOAuthClientMetadataDiscovery.discover(
fromUserSelectedURL: URL(fileURLWithPath: "/Users/test/gemini-cli/bundle", isDirectory: true),
fileLoader: { _ in "" },
directoryLister: { _ in [] }
)
}
}