Files
steipete--codexbar/Sources/CodexBar/Config/CodexBarConfigMigrator.swift
T
2026-07-13 12:22:33 +08:00

374 lines
14 KiB
Swift

import CodexBarCore
import Foundation
struct CodexBarConfigMigrator {
struct LegacyStores {
let zaiTokenStore: any ZaiTokenStoring
let syntheticTokenStore: any SyntheticTokenStoring
let codexCookieStore: any CookieHeaderStoring
let claudeCookieStore: any CookieHeaderStoring
let cursorCookieStore: any CookieHeaderStoring
let opencodeCookieStore: any CookieHeaderStoring
let factoryCookieStore: any CookieHeaderStoring
let minimaxCookieStore: any MiniMaxCookieStoring
let minimaxAPITokenStore: any MiniMaxAPITokenStoring
let kimiTokenStore: any KimiTokenStoring
let kimiK2TokenStore: any KimiK2TokenStoring
let augmentCookieStore: any CookieHeaderStoring
let ampCookieStore: any CookieHeaderStoring
let copilotTokenStore: any CopilotTokenStoring
let tokenAccountStore: any ProviderTokenAccountStoring
}
private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted"
private struct MigrationState {
var didUpdate = false
var sawLegacySecrets = false
var sawLegacyAccounts = false
}
static func loadOrMigrate(
configStore: CodexBarConfigStore,
userDefaults: UserDefaults,
stores: LegacyStores) -> CodexBarConfig
{
let log = CodexBarLog.logger(LogCategories.configMigration)
let existing = try? configStore.load()
var config = (existing ?? CodexBarConfig.makeDefault()).normalized()
var state = MigrationState()
// applyLegacyCookieSources reads only UserDefaults — cheap, runs unconditionally so
// newly-added cookie-source keys are picked up on every launch.
self.applyLegacyCookieSources(userDefaults: userDefaults, config: &config, state: &state)
let migrationCompleted = userDefaults.bool(forKey: Self.legacyMigrationCompletedKey)
if !migrationCompleted {
// Run once: migrate Keychain/file secrets then clear them. Using a completion flag rather
// than `existing == nil` ensures a crash between config-save and clearLegacyStores can
// finish cleanup on the next launch without re-doing the (already-saved) data migration.
if existing == nil {
self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state)
}
self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
self.migrateLegacyAccounts(stores: stores, config: &config, state: &state)
}
var didPersistUpdates = true
if state.didUpdate {
do {
try configStore.save(config)
} catch {
didPersistUpdates = false
log.error("Failed to persist config: \(error)")
}
}
guard didPersistUpdates else {
return config.normalized()
}
if state.sawLegacySecrets || state.sawLegacyAccounts {
let cleared = self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log)
if cleared {
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
}
} else if !migrationCompleted {
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
}
return config.normalized()
}
private static func applyLegacyOrderAndToggles(
userDefaults: UserDefaults,
config: inout CodexBarConfig,
state: inout MigrationState)
{
if let order = userDefaults.stringArray(forKey: "providerOrder"), !order.isEmpty {
config = self.applyProviderOrder(order, config: config)
state.didUpdate = true
}
let toggles = userDefaults.dictionary(forKey: "providerToggles") as? [String: Bool] ?? [:]
if !toggles.isEmpty {
config = self.applyProviderToggles(toggles, config: config)
state.didUpdate = true
}
}
private static func migrateLegacySecrets(
userDefaults: UserDefaults,
stores: LegacyStores,
config: inout CodexBarConfig,
state: inout MigrationState)
{
self.migrateTokenProviders(
[
(.zai, stores.zaiTokenStore.loadToken),
(.synthetic, stores.syntheticTokenStore.loadToken),
(.copilot, stores.copilotTokenStore.loadToken),
(.kimik2, stores.kimiK2TokenStore.loadToken),
],
config: &config,
state: &state)
self.migrateCookieProviders(
[
(.codex, stores.codexCookieStore.loadCookieHeader),
(.claude, stores.claudeCookieStore.loadCookieHeader),
(.cursor, stores.cursorCookieStore.loadCookieHeader),
(.factory, stores.factoryCookieStore.loadCookieHeader),
(.augment, stores.augmentCookieStore.loadCookieHeader),
(.amp, stores.ampCookieStore.loadCookieHeader),
],
config: &config,
state: &state)
self.migrateMiniMax(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
self.migrateKimi(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
self.migrateOpenCode(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
}
private static func applyLegacyCookieSources(
userDefaults: UserDefaults,
config: inout CodexBarConfig,
state: inout MigrationState)
{
let sources: [(UsageProvider, String)] = [
(.codex, "codexCookieSource"),
(.claude, "claudeCookieSource"),
(.cursor, "cursorCookieSource"),
(.opencode, "opencodeCookieSource"),
(.factory, "factoryCookieSource"),
(.minimax, "minimaxCookieSource"),
(.kimi, "kimiCookieSource"),
(.augment, "augmentCookieSource"),
(.amp, "ampCookieSource"),
]
for (provider, key) in sources {
guard let raw = userDefaults.string(forKey: key),
let source = ProviderCookieSource(rawValue: raw)
else { continue }
self.updateProvider(provider, config: &config, state: &state) { entry in
guard entry.cookieSource == nil else { return false }
entry.cookieSource = source
return true
}
}
if userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool == false {
self.updateProvider(.codex, config: &config, state: &state) { entry in
guard entry.cookieSource == nil else { return false }
entry.cookieSource = .off
return true
}
}
}
private static func migrateTokenProviders(
_ providers: [(UsageProvider, () throws -> String?)],
config: inout CodexBarConfig,
state: inout MigrationState)
{
for (provider, loader) in providers {
let token = try? loader()
if token != nil { state.sawLegacySecrets = true }
self.updateProvider(provider, config: &config, state: &state) { entry in
self.setIfEmpty(&entry.apiKey, token)
}
}
}
private static func migrateCookieProviders(
_ providers: [(UsageProvider, () throws -> String?)],
config: inout CodexBarConfig,
state: inout MigrationState)
{
for (provider, loader) in providers {
let header = try? loader()
if header != nil { state.sawLegacySecrets = true }
self.updateProvider(provider, config: &config, state: &state) { entry in
self.setIfEmpty(&entry.cookieHeader, header)
}
}
}
private static func migrateMiniMax(
userDefaults: UserDefaults,
stores: LegacyStores,
config: inout CodexBarConfig,
state: inout MigrationState)
{
let token = try? stores.minimaxAPITokenStore.loadToken()
let header = try? stores.minimaxCookieStore.loadCookieHeader()
if token != nil || header != nil {
state.sawLegacySecrets = true
}
let regionRaw = userDefaults.string(forKey: "minimaxAPIRegion")
self.updateProvider(.minimax, config: &config, state: &state) { entry in
var changed = false
changed = self.setIfEmpty(&entry.apiKey, token) || changed
if let regionRaw, !regionRaw.isEmpty, entry.region == nil {
entry.region = regionRaw
changed = true
}
changed = self.setIfEmpty(&entry.cookieHeader, header) || changed
return changed
}
}
private static func migrateKimi(
userDefaults: UserDefaults,
stores: LegacyStores,
config: inout CodexBarConfig,
state: inout MigrationState)
{
var token = try? stores.kimiTokenStore.loadToken()
if token?.isEmpty ?? true {
token = userDefaults.string(forKey: "kimiManualCookieHeader")
}
if token != nil { state.sawLegacySecrets = true }
self.updateProvider(.kimi, config: &config, state: &state) { entry in
self.setIfEmpty(&entry.cookieHeader, token)
}
}
private static func migrateOpenCode(
userDefaults: UserDefaults,
stores: LegacyStores,
config: inout CodexBarConfig,
state: inout MigrationState)
{
let header = try? stores.opencodeCookieStore.loadCookieHeader()
if header != nil { state.sawLegacySecrets = true }
let workspaceID = userDefaults.string(forKey: "opencodeWorkspaceID")
self.updateProvider(.opencode, config: &config, state: &state) { entry in
var changed = false
changed = self.setIfEmpty(&entry.cookieHeader, header) || changed
if let workspaceID, !workspaceID.isEmpty, entry.workspaceID == nil {
entry.workspaceID = workspaceID
changed = true
}
return changed
}
}
private static func migrateLegacyAccounts(
stores: LegacyStores,
config: inout CodexBarConfig,
state: inout MigrationState)
{
guard let accounts = try? stores.tokenAccountStore.loadAccounts(), !accounts.isEmpty else { return }
state.sawLegacyAccounts = true
for (provider, data) in accounts where !data.accounts.isEmpty {
self.updateProvider(provider, config: &config, state: &state) { entry in
guard entry.tokenAccounts == nil else { return false }
entry.tokenAccounts = data
return true
}
}
}
private static func updateProvider(
_ provider: UsageProvider,
config: inout CodexBarConfig,
state: inout MigrationState,
mutate: (inout ProviderConfig) -> Bool)
{
guard let index = config.providers.firstIndex(where: { $0.id == provider }) else { return }
var entry = config.providers[index]
let changed = mutate(&entry)
if changed {
config.providers[index] = entry
state.didUpdate = true
}
}
private static func setIfEmpty(_ value: inout String?, _ replacement: String?) -> Bool {
let cleaned = replacement?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let cleaned, !cleaned.isEmpty else { return false }
if value == nil || value?.isEmpty == true {
value = cleaned
return true
}
return false
}
@discardableResult
private static func clearLegacyStores(
stores: LegacyStores,
sawAccounts: Bool,
log: CodexBarLogger) -> Bool
{
var success = true
do {
try stores.zaiTokenStore.storeToken(nil)
try stores.syntheticTokenStore.storeToken(nil)
try stores.copilotTokenStore.storeToken(nil)
try stores.minimaxAPITokenStore.storeToken(nil)
try stores.kimiTokenStore.storeToken(nil)
try stores.kimiK2TokenStore.storeToken(nil)
try stores.codexCookieStore.storeCookieHeader(nil)
try stores.claudeCookieStore.storeCookieHeader(nil)
try stores.cursorCookieStore.storeCookieHeader(nil)
try stores.opencodeCookieStore.storeCookieHeader(nil)
try stores.factoryCookieStore.storeCookieHeader(nil)
try stores.minimaxCookieStore.storeCookieHeader(nil)
try stores.augmentCookieStore.storeCookieHeader(nil)
try stores.ampCookieStore.storeCookieHeader(nil)
} catch {
log.error("Failed to clear legacy secrets: \(error)")
success = false
}
if sawAccounts {
let legacyURL = FileTokenAccountStore.defaultURL()
if FileManager.default.fileExists(atPath: legacyURL.path) {
try? FileManager.default.removeItem(at: legacyURL)
}
}
return success
}
private static func applyProviderOrder(_ raw: [String], config: CodexBarConfig) -> CodexBarConfig {
let configsByID = Dictionary(uniqueKeysWithValues: config.providers.map { ($0.id, $0) })
var seen: Set<UsageProvider> = []
var ordered: [ProviderConfig] = []
ordered.reserveCapacity(config.providers.count)
for rawValue in raw {
guard let provider = UsageProvider(rawValue: rawValue),
let entry = configsByID[provider],
!seen.contains(provider)
else { continue }
seen.insert(provider)
ordered.append(entry)
}
for provider in UsageProvider.allCases where !seen.contains(provider) {
ordered.append(configsByID[provider] ?? ProviderConfig(id: provider))
}
var updated = config
updated.providers = ordered
return updated
}
private static func applyProviderToggles(
_ toggles: [String: Bool],
config: CodexBarConfig) -> CodexBarConfig
{
var updated = config
for index in updated.providers.indices {
let provider = updated.providers[index].id
let meta = ProviderDescriptorRegistry.descriptor(for: provider).metadata
if let value = toggles[meta.cliName] {
updated.providers[index].enabled = value
}
}
return updated
}
}