Files
openless/Sources/OpenLessPersistence/CredentialsMigration.swift
T
baiqing c072816beb feat(persistence): migrate CredentialsVault to versioned v1 schema
Split flat v0 credentials.json into version=1 schema with provider
sections (asr/llm), each keyed by providerId. Add automatic migration
on first load with safe backup of the original v0 file.

- New types: CredentialsSchemaV1, CredentialsProviderASRVolcengine,
  CredentialsProviderLLMArk, CredentialsActiveSelection
- New CredentialsError enum: unparseable / futureVersion / ioError /
  backupFailed / writeFailed
- Pure migration function (CredentialsMigration.parseAndMigrate) for
  unit testability — no disk I/O
- Vault: atomic write via tmp + replaceItemAt, mode 0600 preserved
- Backup format: credentials.v0.bak.<timestamp>[-N].json
- Existing public API (get/set/remove/snapshot, CredentialAccount
  constants) is unchanged so SettingsHubTab / Sidebar continue to work
- New OpenLessPersistenceTests target with 13 migration tests covering
  fresh install, full v0, partial v0 (asr-only / llm-only), empty
  ark.endpoint default, future-version safety, damaged-JSON safety,
  backup-name conflict suffix, idempotent save, atomic write, and
  0600 permissions
2026-04-29 11:25:13 +08:00

158 lines
6.6 KiB
Swift
Raw 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
/// 纯函数迁移:v0 扁平字典 → v1 schema。
///
/// 设计原则:
/// - **纯解析 / 翻译**,不做任何磁盘 IO。便于单元测试。
/// - 输入是已经从磁盘读出的原始 bytes / 解析过的字典;输出是判定结果(v1 model + 是否需要备份)。
/// - 检测到 `version` 字段:v1 直接 decode;version > 1 抛 `futureVersion`。
/// - 没有 `version` 字段:当作 v0,按 v0→v1 字段映射规则翻译。
///
/// 字段映射(v0 扁平字典 → v1):
/// - `volcengine.app_key` → `providers.asr.volcengine.appKey`
/// - `volcengine.access_key` → `providers.asr.volcengine.accessKey`
/// - `volcengine.resource_id` → `providers.asr.volcengine.resourceId`
/// - `ark.api_key` → `providers.llm.ark.apiKey`
/// - `ark.endpoint` → `providers.llm.ark.baseURL` (空或缺失时回落 default)
/// - `ark.model_id` → `providers.llm.ark.model`
///
/// 兼容备注:历史上 v0 既出现过点号风格(`volcengine.app_key`),也可能在外部文档里见到下划线风格
/// (`volcengine_app_key`)。这里两种都接受,避免外部转储的文件迁移失败。
public enum CredentialsMigration {
/// 解析 / 迁移结果。
public struct Result: Equatable, Sendable {
public let schema: CredentialsSchemaV1
/// 是否检测到 v0 数据并完成翻译,调用方需要执行"备份原文件 + 写新 v1 文件"。
public let needsMigrationFromV0: Bool
public init(schema: CredentialsSchemaV1, needsMigrationFromV0: Bool) {
self.schema = schema
self.needsMigrationFromV0 = needsMigrationFromV0
}
}
/// 主入口:对原始 bytes 做版本嗅探并产出 v1 schema。
///
/// - Throws: `CredentialsError.unparseable(fileURL)` 当 JSON 不能解析;
/// `CredentialsError.futureVersion(n)` 当 version > 1。
public static func parseAndMigrate(rawData: Data, fileURL: URL) throws -> Result {
let json: Any
do {
json = try JSONSerialization.jsonObject(with: rawData, options: [])
} catch {
throw CredentialsError.unparseable(fileURL)
}
guard let dict = json as? [String: Any] else {
throw CredentialsError.unparseable(fileURL)
}
// 显式版本:直接走对应版本路径。
if let version = dict["version"] as? Int {
if version == 1 {
let schema = try decodeV1(rawData: rawData, fileURL: fileURL)
return Result(schema: schema, needsMigrationFromV0: false)
}
if version > 1 {
throw CredentialsError.futureVersion(version)
}
// version == 0 或负数:当作 v0 处理(极少见,做兜底)。
}
// 没有 version 字段(或 version <= 0):v0 扁平字典 → v1。
let schema = translateV0Dict(dict)
return Result(schema: schema, needsMigrationFromV0: true)
}
// MARK: - v1 解码
private static func decodeV1(rawData: Data, fileURL: URL) throws -> CredentialsSchemaV1 {
do {
return try JSONDecoder().decode(CredentialsSchemaV1.self, from: rawData)
} catch {
throw CredentialsError.unparseable(fileURL)
}
}
// MARK: - v0 → v1 字段翻译
/// v0 扁平字典翻译成 v1 schema。纯函数,调用方负责处理是否需要备份 / 写盘。
public static func translateV0Dict(_ dict: [String: Any]) -> CredentialsSchemaV1 {
let v0 = readV0Fields(dict)
var providers = CredentialsProviders()
// Volcengine:任一字段非空才落 provider 节。
let volcAppKey = nilIfEmpty(v0.volcengineAppKey)
let volcAccessKey = nilIfEmpty(v0.volcengineAccessKey)
let volcResourceId = nilIfEmpty(v0.volcengineResourceId)
if volcAppKey != nil || volcAccessKey != nil || volcResourceId != nil {
providers.asr[defaultActiveASRProviderId] = CredentialsProviderASRVolcengine(
appKey: volcAppKey,
accessKey: volcAccessKey,
resourceId: volcResourceId
)
}
// Ark:任一字段非空才落 provider 节。endpoint 空或缺失 → 落 default baseURL。
let arkApiKey = nilIfEmpty(v0.arkApiKey)
let arkModel = nilIfEmpty(v0.arkModelId)
let arkEndpointRaw = v0.arkEndpoint
let arkEndpointTrimmed = arkEndpointRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
let arkBaseURL: String? = {
if let v = arkEndpointTrimmed, !v.isEmpty { return v }
return defaultArkBaseURL
}()
if arkApiKey != nil || arkModel != nil || (arkEndpointTrimmed?.isEmpty == false) {
providers.llm[defaultActiveLLMProviderId] = CredentialsProviderLLMArk(
apiKey: arkApiKey,
baseURL: arkBaseURL,
model: arkModel
)
}
return CredentialsSchemaV1(
version: 1,
providers: providers,
active: .defaults
)
}
// MARK: - v0 字段读取(容忍点号 / 下划线两种风格)
/// 读取 v0 扁平字典里的 6 个已知字段。点号 (`volcengine.app_key`) 是实际写盘格式,
/// 同时接受下划线 (`volcengine_app_key`) 以兼容外部转储的文件。
private static func readV0Fields(_ dict: [String: Any]) -> V0Fields {
V0Fields(
volcengineAppKey: stringValue(dict, keys: ["volcengine.app_key", "volcengine_app_key"]),
volcengineAccessKey: stringValue(dict, keys: ["volcengine.access_key", "volcengine_access_key"]),
volcengineResourceId: stringValue(dict, keys: ["volcengine.resource_id", "volcengine_resource_id"]),
arkApiKey: stringValue(dict, keys: ["ark.api_key", "ark_api_key"]),
arkModelId: stringValue(dict, keys: ["ark.model_id", "ark_model_id"]),
arkEndpoint: stringValue(dict, keys: ["ark.endpoint", "ark_endpoint"])
)
}
private struct V0Fields {
let volcengineAppKey: String?
let volcengineAccessKey: String?
let volcengineResourceId: String?
let arkApiKey: String?
let arkModelId: String?
let arkEndpoint: String?
}
private static func stringValue(_ dict: [String: Any], keys: [String]) -> String? {
for k in keys {
if let v = dict[k] as? String { return v }
}
return nil
}
private static func nilIfEmpty(_ s: String?) -> String? {
guard let s else { return nil }
return s.isEmpty ? nil : s
}
}