feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree. - feat(keyboard): CursorNavigation + CursorDragPad for caret movement; KeyboardSoundFeedback for system key click sounds - feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet; TranscriptPostProcessor quality gate; retire DictionaryLearner - feat(ui): TabBarVisibility handling; drop PageHeaderRow / PageHeaderConfirmButton; refresh views and localizable strings - fix(security): move the hardcoded DeepSeek key out of PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift (seeded from .example by generate-xcodeproj.sh) - docs(agents): add Conventional Commits versioning + bilingual changelog rules - chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache Custom language model / lexicon work stays on feature/custom-language-model-asr. Changelog bullets added under [Unreleased]; no version bump.
This commit is contained in:
@@ -46,7 +46,7 @@ public enum AppContext: String, Codable, Sendable, CaseIterable {
|
||||
case .email:
|
||||
return "Email context: you may add a polite greeting or sign-off if the user clearly forgot one. Reasonable paragraph breaks. Keep tone professional but not stiff."
|
||||
case .chat:
|
||||
return "Chat context: keep it short, conversational, and emoji-friendly. Drop formalities. Preserve the speaker's casual voice."
|
||||
return "Chat context: keep it short, conversational, and natural. Drop formalities. Preserve the speaker's casual voice. Do not add emojis."
|
||||
case .document:
|
||||
return "Document context: add structure — split into paragraphs, use lists when the user enumerates. Keep tone written-formal. Do not invent headings the user did not say."
|
||||
case .unknown:
|
||||
|
||||
@@ -52,13 +52,11 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
defaultBaseURL: "https://api.deepseek.com/v1",
|
||||
// v0.2.0: bumped default to `deepseek-v4-flash` for the
|
||||
// local-mode cloud-polish toggle. `deepseek-chat` is
|
||||
// retained as a valid user-overridable model name; only
|
||||
// the default is updated.
|
||||
defaultModel: "deepseek-v4-flash",
|
||||
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
|
||||
blurb: "deepseek-v4-flash · 默认 · 快速且中文友好"
|
||||
blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in",
|
||||
// Local engine only — never shown in cloud-engine pickers.
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "qwen",
|
||||
@@ -96,4 +94,10 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
public static func provider(id: String) -> LLMProvider {
|
||||
presets.first(where: { $0.id == id }) ?? .presets[0]
|
||||
}
|
||||
|
||||
/// Presets the user may pick in Settings / onboarding. DeepSeek is
|
||||
/// excluded — it is wired exclusively to the local engine.
|
||||
public static var userSelectablePresets: [LLMProvider] {
|
||||
presets.filter(\.isUserSelectable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
//
|
||||
// Sources (mutually exclusive per entry):
|
||||
// - `.manual` user typed it in by hand
|
||||
// - `.history` auto-extracted from the user's transcription
|
||||
// history by `DictionaryLearner`
|
||||
// - `.history` legacy auto-learned entries (migrated to `.manual`)
|
||||
// - `.contacts` imported from the iOS Contacts framework
|
||||
// - `.recentEdit` extracted from edits the user made to a
|
||||
// polished transcript before sending
|
||||
@@ -104,13 +103,124 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
extension PersonalDictionary.Entry {
|
||||
/// Lightweight category inference for manual adds and the history
|
||||
/// learner. Users can re-classify later from Settings.
|
||||
public static func inferCategory(for term: String) -> Category {
|
||||
let hasUpper = term.contains(where: { $0.isUppercase })
|
||||
let hasDigit = term.contains(where: { $0.isNumber })
|
||||
let hasLatin = term.unicodeScalars.contains { scalar in
|
||||
CharacterSet.letters.contains(scalar) && scalar.isASCII
|
||||
}
|
||||
if hasUpper, !term.contains(where: { $0.isLowercase }) {
|
||||
return .acronym
|
||||
}
|
||||
if hasDigit {
|
||||
return .productName
|
||||
}
|
||||
if !hasLatin {
|
||||
return .properNoun
|
||||
}
|
||||
return .productName
|
||||
}
|
||||
}
|
||||
|
||||
extension PersonalDictionary {
|
||||
public static let empty = PersonalDictionary()
|
||||
|
||||
/// Built-in terms always included in LLM prompts. Never persisted
|
||||
/// and never shown in the Settings personal-dictionary UI.
|
||||
public static let systemEntries: [Entry] = [
|
||||
Entry(
|
||||
id: UUID(uuidString: "A0000000-0000-4000-8000-000000000001")!,
|
||||
term: "OSGKeyboard",
|
||||
aliases: [],
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
usageCount: 0
|
||||
),
|
||||
]
|
||||
|
||||
/// User entries plus built-in system terms (deduped by term).
|
||||
public var effectiveEntries: [Entry] {
|
||||
var merged = Self.systemEntries
|
||||
let systemTerms = Set(Self.systemEntries.map { $0.term.lowercased() })
|
||||
for entry in entries where !systemTerms.contains(entry.term.lowercased()) {
|
||||
merged.append(entry)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/// Case-insensitive lookup by canonical term.
|
||||
public func entry(matchingTerm term: String) -> Entry? {
|
||||
let key = term.lowercased()
|
||||
return entries.first { $0.term.lowercased() == key }
|
||||
}
|
||||
|
||||
/// Insert or update a manual entry. Returns the saved entry.
|
||||
@discardableResult
|
||||
public mutating func upsertManual(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
regenerateAliases: Bool = false
|
||||
) -> Entry? {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let category = Entry.inferCategory(for: trimmed)
|
||||
|
||||
if let existingID,
|
||||
let idx = entries.firstIndex(where: { $0.id == existingID }) {
|
||||
var entry = entries[idx]
|
||||
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
if termChanged || regenerateAliases {
|
||||
entry.aliases = []
|
||||
}
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
if let idx = entries.firstIndex(where: {
|
||||
$0.term.caseInsensitiveCompare(trimmed) == .orderedSame
|
||||
}) {
|
||||
var entry = entries[idx]
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
let entry = Entry(
|
||||
term: trimmed,
|
||||
aliases: [],
|
||||
category: category,
|
||||
source: .manual
|
||||
)
|
||||
entries.append(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
public mutating func updateAliases(for entryID: UUID, aliases: [String]) {
|
||||
guard let idx = entries.firstIndex(where: { $0.id == entryID }) else { return }
|
||||
let cleaned = aliases
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let termLower = entries[idx].term.lowercased()
|
||||
entries[idx].aliases = Array(
|
||||
Set(cleaned.filter { $0.lowercased() != termLower })
|
||||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Renders the entire dictionary as a prompt fragment. Entries
|
||||
/// are grouped by category so the LLM can scan quickly. Empty
|
||||
/// dictionary returns "" so the caller can blindly concatenate.
|
||||
public func promptFragment() -> String {
|
||||
let entries = effectiveEntries
|
||||
guard !entries.isEmpty else { return "" }
|
||||
let grouped = Dictionary(grouping: entries, by: { $0.category })
|
||||
var lines: [String] = []
|
||||
|
||||
@@ -9,14 +9,9 @@
|
||||
import Foundation
|
||||
|
||||
public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// Engine is in pure ASR mode (local + cloud-polish-off). The LLM
|
||||
/// is never called; the raw transcript is inserted as-is. This
|
||||
/// value is mostly a UI default — the actual behaviour is
|
||||
/// determined by `engineMode` + `localModeCloudPolishEnabled`.
|
||||
case off
|
||||
|
||||
/// Drop only isolated filler words (嗯 / 呃 / 那个 / 就是 / 然后)
|
||||
/// and obvious duplicated fragments. Everything else stays.
|
||||
/// and obvious duplicated fragments. Punctuation and structure
|
||||
/// formatting still apply at every intensity level.
|
||||
case light
|
||||
|
||||
/// Correction + light polish: drop fillers, fix homophone errors,
|
||||
@@ -34,7 +29,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// and the keyboard extension.
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .off: return "polish.intensity.off"
|
||||
case .light: return "polish.intensity.light"
|
||||
case .medium: return "polish.intensity.medium"
|
||||
case .heavy: return "polish.intensity.heavy"
|
||||
@@ -45,7 +39,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// story as `labelKey`.
|
||||
public var descriptionKey: String {
|
||||
switch self {
|
||||
case .off: return "polish.intensity.off.desc"
|
||||
case .light: return "polish.intensity.light.desc"
|
||||
case .medium: return "polish.intensity.medium.desc"
|
||||
case .heavy: return "polish.intensity.heavy.desc"
|
||||
@@ -57,16 +50,38 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// non-ambiguous constraint per call.
|
||||
public var promptGuideline: String {
|
||||
switch self {
|
||||
case .off:
|
||||
return "Do not change the input at all. Output the original text verbatim."
|
||||
case .light:
|
||||
return "Only remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok) and obvious duplicated fragments. Do not change any other words, word order, or punctuation."
|
||||
return """
|
||||
Light rewrite: remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok, um, uh) and obvious duplicated fragments only. \
|
||||
Do not rephrase otherwise-clear wording. \
|
||||
Still restore punctuation, sentence breaks, and content-triggered structure (lists, paragraphs) per the global output contract.
|
||||
"""
|
||||
case .medium:
|
||||
return "Correct obvious speech-recognition errors (homophones, missing/extra characters). Remove filler words and duplicated fragments. Adjust obviously-broken word order. Add punctuation. Do not restructure sentences, invent facts, or change the speaker's voice."
|
||||
return """
|
||||
Medium rewrite: fix obvious ASR errors (homophones, missing/extra characters), remove fillers and duplicated fragments, \
|
||||
adjust obviously-broken word order. Preserve the speaker's voice. \
|
||||
Still restore punctuation, sentence breaks, and content-triggered structure per the global output contract. \
|
||||
Do not invent facts or change numbers/proper nouns.
|
||||
"""
|
||||
case .heavy:
|
||||
return "Apply medium corrections, then optionally restructure: split long sentences, auto-number enumerated items into markdown lists, group related ideas into paragraphs. Preserve every fact, number, and proper noun."
|
||||
return """
|
||||
Heavy rewrite: apply medium corrections, then you may reorganize paragraphs, split long sentences, and listify enumerated content. \
|
||||
Punctuation and structure are mandatory at every intensity. \
|
||||
Preserve every fact, number, and proper noun. Do not add information.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy persisted value `"off"` maps to `.medium` on read.
|
||||
public static func resolve(storedRawValue raw: String) -> PolishIntensity {
|
||||
if raw == legacyOffRawValue {
|
||||
return .medium
|
||||
}
|
||||
return PolishIntensity(rawValue: raw) ?? .default
|
||||
}
|
||||
|
||||
/// Raw value written by builds before the off tier was removed.
|
||||
public static let legacyOffRawValue = "off"
|
||||
}
|
||||
|
||||
extension PolishIntensity {
|
||||
|
||||
@@ -51,12 +51,21 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// "on" state during init, but new writes never touch the key.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let handednessPreference = "config.handednessPreference"
|
||||
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
// v0.3.0: how aggressively the LLM should rewrite transcripts.
|
||||
static let polishIntensity = "config.polishIntensity"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
didSet { defaults.set(providerId, forKey: Key.providerId) }
|
||||
didSet {
|
||||
defaults.set(providerId, forKey: Key.providerId)
|
||||
// Keep API keys isolated per provider: switching provider in
|
||||
// Settings loads that provider's key instead of reusing the
|
||||
// previously selected vendor's key.
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = Keychain.apiKey(for: providerId) ?? ""
|
||||
isSyncingProviderAPIKey = false
|
||||
}
|
||||
}
|
||||
@Published public var baseURL: String {
|
||||
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
||||
@@ -65,9 +74,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
// Skip the round-trip on init — we read from Keychain and
|
||||
// writing the same value back is wasteful.
|
||||
guard oldValue != apiKey else { return }
|
||||
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
||||
do {
|
||||
try Keychain.setAPIKey(apiKey)
|
||||
try Keychain.setAPIKey(apiKey, for: providerId)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
|
||||
@@ -84,10 +93,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
@Published public var localeId: String {
|
||||
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
||||
}
|
||||
/// "local" → on-device ASR only (raw transcript delivery).
|
||||
/// "cloud" → ASR + LLM polish (always on; modeId kept for compatibility).
|
||||
/// "local" → on-device ASR + built-in DeepSeek polish.
|
||||
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
||||
@Published public var engineMode: String {
|
||||
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
|
||||
didSet {
|
||||
defaults.set(engineMode, forKey: Key.engineMode)
|
||||
applyEngineModeSideEffects()
|
||||
}
|
||||
}
|
||||
@Published public var hasCompletedOnboarding: Bool {
|
||||
didSet {
|
||||
@@ -162,27 +174,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Cloud engine: any selected target locale. Local engine:
|
||||
/// only when cloud polish is also enabled.
|
||||
public var isTranslationEffective: Bool {
|
||||
guard translationEnabled else { return false }
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool {
|
||||
didSet {
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Key.cursorDragNavigationEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// Translation picker visibility. Cloud engine: always. Local engine:
|
||||
/// only when "Cloud polish after ASR" is on — translation is a
|
||||
/// sub-step of that cloud LLM pass, not a standalone feature.
|
||||
public var isTranslationRowVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return isLocalEngine && localModeCloudPolishEnabled
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Both engines honour the selected target locale.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
}
|
||||
|
||||
/// Translation picker visibility — available on both engines.
|
||||
public var isTranslationRowVisible: Bool { true }
|
||||
|
||||
/// v0.3.0: how aggressively the LLM should rewrite the ASR
|
||||
/// transcript. Default is `medium` (Typeless-equivalent). The
|
||||
/// `off` value never calls the LLM — equivalent to "transcribe
|
||||
/// only" regardless of `engineMode`.
|
||||
/// transcript. Default is `medium` (Typeless-equivalent).
|
||||
@Published public var polishIntensity: PolishIntensity {
|
||||
didSet { defaults.set(polishIntensity.rawValue, forKey: Key.polishIntensity) }
|
||||
}
|
||||
@@ -200,32 +210,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// On-device ASR only; no cloud API required.
|
||||
public var isLocalEngine: Bool { engineMode == "local" }
|
||||
|
||||
/// Whether a transcript produced by the local engine should be
|
||||
/// sent through the cloud LLM polish step before insertion.
|
||||
///
|
||||
/// v0.2.0: the local engine defaults to ASR-only. When the user
|
||||
/// enables "Cloud polish after ASR" (`localModeCloudPolishEnabled`)
|
||||
/// we route the transcript through the configured LLM (DeepSeek by
|
||||
/// default in local mode) — same `PolishingService` code path the
|
||||
/// cloud engine uses.
|
||||
///
|
||||
/// If the user hasn't entered an API key we can't run the polish
|
||||
/// step; callers should check `Keychain.apiKey()` before invoking.
|
||||
public var shouldPolishLocalTranscript: Bool {
|
||||
isLocalEngine && localModeCloudPolishEnabled
|
||||
}
|
||||
/// Local engine always polishes via the built-in DeepSeek path.
|
||||
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
|
||||
|
||||
/// v0.2.1 follow-up: when the local engine is using the cloud-
|
||||
/// polish step, route the call through DeepSeek — cheap, strong
|
||||
/// on Chinese, and the right default for the on-device ASR
|
||||
/// transcript. Other engines honor the user's configured
|
||||
/// `providerId` unchanged so cloud users keep their preferred
|
||||
/// vendor (OpenAI / Anthropic / Zhipu / etc).
|
||||
public var localModeProviderId: String {
|
||||
isLocalEngine ? "deepseek" : providerId
|
||||
}
|
||||
/// Cloud engine uses `providerId`. Local engine pins DeepSeek.
|
||||
public var localModeProviderId: String { "deepseek" }
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var isSyncingProviderAPIKey = false
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
|
||||
@@ -239,7 +231,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
|
||||
// is empty in the suite and all subsequent reads go through the
|
||||
// Keychain.
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults)
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults, providerId: pid)
|
||||
|
||||
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
|
||||
@@ -278,12 +270,18 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
self.handednessPreference = HandednessPreference.fromStored(
|
||||
resolvedDefaults.string(forKey: Key.handednessPreference)
|
||||
)
|
||||
if resolvedDefaults.object(forKey: Key.cursorDragNavigationEnabled) == nil {
|
||||
self.cursorDragNavigationEnabled = true
|
||||
} else {
|
||||
self.cursorDragNavigationEnabled = resolvedDefaults.bool(forKey: Key.cursorDragNavigationEnabled)
|
||||
}
|
||||
// v0.3.0: polish intensity. Default to `.medium` for new
|
||||
// installs and upgrades; the existing `off` / `light` /
|
||||
// `heavy` values are honored.
|
||||
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity),
|
||||
let intensity = PolishIntensity(rawValue: raw) {
|
||||
self.polishIntensity = intensity
|
||||
// installs; legacy `"off"` migrates to `.medium`.
|
||||
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity) {
|
||||
self.polishIntensity = PolishIntensity.resolve(storedRawValue: raw)
|
||||
if raw == PolishIntensity.legacyOffRawValue {
|
||||
resolvedDefaults.set(PolishIntensity.medium.rawValue, forKey: Key.polishIntensity)
|
||||
}
|
||||
} else {
|
||||
self.polishIntensity = .default
|
||||
}
|
||||
@@ -292,17 +290,36 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||
self.modeId = "polish"
|
||||
}
|
||||
// DeepSeek is local-engine only — never a cloud picker choice.
|
||||
if self.engineMode == "cloud", self.providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep cloud vs local provider choices isolated when the user
|
||||
/// switches engines in Settings / onboarding.
|
||||
private func applyEngineModeSideEffects() {
|
||||
if engineMode == "cloud", providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time
|
||||
/// migration from the legacy UserDefaults slot.
|
||||
private static func resolveAPIKey(defaults: UserDefaults) -> String {
|
||||
if let stored = Keychain.apiKey(), !stored.isEmpty {
|
||||
private static func resolveAPIKey(defaults: UserDefaults, providerId: String) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
// Migration path: old builds stored one global key under
|
||||
// Keychain account "current". Move it to the active provider.
|
||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
||||
try? Keychain.deleteLegacyAPIKey()
|
||||
return legacyKeychain
|
||||
}
|
||||
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
|
||||
!legacy.isEmpty {
|
||||
try? Keychain.setAPIKey(legacy)
|
||||
try? Keychain.setAPIKey(legacy, for: providerId)
|
||||
defaults.removeObject(forKey: Key.apiKeyLegacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user