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
8 changes: 8 additions & 0 deletions OpenVision.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
1D0AEF3C7A810FF410C8F2E5 /* SessionRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B8E5499FB1BA51EF6F1741 /* SessionRecorder.swift */; };
1EEA4B0E379A76881244FAB9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE51DA812A9394A5064F60E /* SettingsView.swift */; };
223474C328BCC3C81ADCB93D /* OpenAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDF4A693D2CEB248757FBD3 /* OpenAIService.swift */; };
F999D96ED7C879D8C3826786 /* SettingsSecretsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2D0406DCB5F627611BBD2B4 /* SettingsSecretsTests.swift */; };
8A6467FB1A9E725FBEF8B262 /* CloudTTSFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D73C5CCFE6382A4B4234D4A /* CloudTTSFallbackTests.swift */; };
2241E9878F41D5D5856C9502 /* HermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641FA355FB2C0B37F5575730 /* HermesTests.swift */; };
23D894B8F06FA0DBE93C658B /* NeuralSpeech.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82F52D647139EC398E043BF /* NeuralSpeech.swift */; };
Expand Down Expand Up @@ -118,6 +119,7 @@
F1D86E9A3AB2A994A205AB83 /* GeminiSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 773753CA73EBA9F8C78BD8E4 /* GeminiSettingsView.swift */; };
F5155F1DD262090EC1804C5F /* SwirlOrb.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E4A28F9C0979733D4ABDB2 /* SwirlOrb.swift */; };
F6489F185DBE88FE282819D1 /* DocumentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF763EF8FCBF8EC57D8B943 /* DocumentStore.swift */; };
92C71533063AC23805A32861 /* SettingsSecrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F1F8FB3F5F4D700EA591B /* SettingsSecrets.swift */; };
F7718545E4E6BB4A7773B564 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADC230E2AE44E5132DC5AB01 /* SettingsManager.swift */; };
F7AB848D2FF4A3A019358670 /* CloudChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6327DA000918E311DF6E9B8 /* CloudChat.swift */; };
F7B70D8A815C84149A038294 /* HermesGateway.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7B8054D97114E3269E13EC /* HermesGateway.swift */; };
Expand Down Expand Up @@ -179,6 +181,7 @@
60E2210A5A20177647BDB5AD /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
6102B753E2737E698B780C8E /* HermesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesSettingsView.swift; sourceTree = "<group>"; };
6188DD26571BACB30096654D /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = "<group>"; };
A2D0406DCB5F627611BBD2B4 /* SettingsSecretsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSecretsTests.swift; sourceTree = "<group>"; };
5D73C5CCFE6382A4B4234D4A /* CloudTTSFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTTSFallbackTests.swift; sourceTree = "<group>"; };
641FA355FB2C0B37F5575730 /* HermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesTests.swift; sourceTree = "<group>"; };
669EB2820345D230AB0F9FAC /* MetricsCollector+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetricsCollector+Configuration.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -206,6 +209,7 @@
A82F52D647139EC398E043BF /* NeuralSpeech.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeuralSpeech.swift; sourceTree = "<group>"; };
A900F00D2451513A29BC4C6F /* AIBackendProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIBackendProtocol.swift; sourceTree = "<group>"; };
ACA3BB4BDC73148785327C9A /* OpenVisionTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = OpenVisionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B50F1F8FB3F5F4D700EA591B /* SettingsSecrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSecrets.swift; sourceTree = "<group>"; };
ADC230E2AE44E5132DC5AB01 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
ADCFF581A7AE50FBF35EA257 /* GeminiLiveService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiLiveService.swift; sourceTree = "<group>"; };
B2B78D6C28BEFF1F8426997C /* AudioCaptureService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioCaptureService.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -572,6 +576,7 @@
children = (
9BCCC84E6880175BD83F37FD /* ConversationManager.swift */,
8DD25C635E9A37E7D6CA7677 /* GlassesManager.swift */,
B50F1F8FB3F5F4D700EA591B /* SettingsSecrets.swift */,
ADC230E2AE44E5132DC5AB01 /* SettingsManager.swift */,
);
path = Managers;
Expand Down Expand Up @@ -664,6 +669,7 @@
39552315579536D518404FED /* DocumentChunkingTests.swift */,
B88E1A3CDAF6C7B3D7DF36FE /* FrameChangeTests.swift */,
3A5609ACD2D7FE78F322E775 /* GrokTests.swift */,
A2D0406DCB5F627611BBD2B4 /* SettingsSecretsTests.swift */,
5D73C5CCFE6382A4B4234D4A /* CloudTTSFallbackTests.swift */,
641FA355FB2C0B37F5575730 /* HermesTests.swift */,
DD3DFE96152AAE9145F104D6 /* LocalAgentRouteTests.swift */,
Expand Down Expand Up @@ -839,6 +845,7 @@
E9628B20FB4654FBBFC61EF9 /* DocumentChunkingTests.swift in Sources */,
9A7811CF026E8DF74F74C2A2 /* FrameChangeTests.swift in Sources */,
41DD47E61F53A95E861466C1 /* GrokTests.swift in Sources */,
F999D96ED7C879D8C3826786 /* SettingsSecretsTests.swift in Sources */,
8A6467FB1A9E725FBEF8B262 /* CloudTTSFallbackTests.swift in Sources */,
2241E9878F41D5D5856C9502 /* HermesTests.swift in Sources */,
DC6776A7CBFBB8E151FB6B85 /* LocalAgentRouteTests.swift in Sources */,
Expand Down Expand Up @@ -928,6 +935,7 @@
5F9D55BB6AA2E4CF8AF85A49 /* PomodoroTool.swift in Sources */,
CB6636C853387785316153FD /* ReminderTool.swift in Sources */,
1D0AEF3C7A810FF410C8F2E5 /* SessionRecorder.swift in Sources */,
92C71533063AC23805A32861 /* SettingsSecrets.swift in Sources */,
F7718545E4E6BB4A7773B564 /* SettingsManager.swift in Sources */,
1EEA4B0E379A76881244FAB9 /* SettingsView.swift in Sources */,
6C065C29CC13488E67A70363 /* SoundService.swift in Sources */,
Expand Down
109 changes: 105 additions & 4 deletions OpenVision/Managers/SettingsManager.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// OpenVision - SettingsManager.swift
// Singleton manager for settings persistence with debounced auto-save
// Singleton manager for settings persistence with debounced auto-save.
// API keys and tokens are saved to the Keychain (SettingsSecrets), not settings.json.

import Foundation
import Combine
import UIKit

/// Manages app settings with JSON persistence and debounced saving
@MainActor
Expand All @@ -28,6 +30,22 @@ final class SettingsManager: ObservableObject {
private var saveTask: Task<Void, Never>?
private let debounceInterval: TimeInterval = 0.5

/// What the Keychain holds for each secret, so a save only writes the ones that changed.
private var storedSecrets: [String: String] = [:]
/// Secrets the Keychain couldn't be read for (before first unlock). Never written or deleted
/// until a read succeeds, so a locked launch can't wipe them.
private var unreadableSecrets: Set<String> = []
/// Secrets whose Keychain write failed: kept in settings.json rather than lost.
private var secretsKeptInFile: Set<String> = []
/// Unreadable secrets the user reset: deleted, not restored, once the Keychain can be read.
private var secretsToClear: Set<String> = []
/// Cleared secrets whose Keychain delete failed. Kept across launches, so a value still in
/// the Keychain isn't loaded back; the delete is retried on every save until it succeeds.
private var pendingDeletions = Set(UserDefaults.standard.stringArray(forKey: SettingsManager.pendingDeletionsKey) ?? []) {
didSet { UserDefaults.standard.set(Array(pendingDeletions), forKey: Self.pendingDeletionsKey) }
}
private static let pendingDeletionsKey = "SettingsSecrets.pendingDeletions"

// MARK: - Callbacks

/// Called when settings change (for live session updates)
Expand All @@ -40,10 +58,63 @@ final class SettingsManager: ObservableObject {
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
settingsURL = documentsURL.appendingPathComponent("settings.json")

// Load existing settings or create defaults
settings = Self.loadSettings(from: settingsURL)
// Load existing settings or create defaults, then the secrets from the Keychain
let fileSettings = Self.loadSettings(from: settingsURL)
for account in pendingDeletions where SettingsSecrets.write("", account: account) {
pendingDeletions.remove(account)
}
var keychain: [String: SettingsSecrets.Stored] = [:]
for (account, _) in SettingsSecrets.fields {
// A key the user cleared stays cleared, even if the Keychain still has it.
keychain[account] = pendingDeletions.contains(account) ? .none : SettingsSecrets.read(account)
}
let merged = SettingsSecrets.merge(file: fileSettings, keychain: keychain)
settings = merged.settings
unreadableSecrets = merged.unreadable
for (account, stored) in keychain { if case .value(let value) = stored { storedSecrets[account] = value } }

print("[SettingsManager] Initialized with settings from: \(settingsURL.path)")

// A settings.json from an older build still has keys in plain text: move them now.
if SettingsSecrets.fields.contains(where: { !fileSettings[keyPath: $0.keyPath].isEmpty }) {
print("[SettingsManager] Moving API keys from settings.json to the Keychain")
saveNow()
}

// A launch before first unlock (Bluetooth relaunch after a reboot) can't read the
// Keychain: load the keys as soon as it can, without waiting for a settings change.
let center = NotificationCenter.default
for name in [UIApplication.protectedDataDidBecomeAvailableNotification, UIApplication.didBecomeActiveNotification] {
center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.reloadUnreadableSecrets() }
}
}
}

/// Read again the secrets the Keychain couldn't give us at launch, and put them back into
/// `settings` so the backends see them (the change saves and fires `onSettingsChanged`).
func reloadUnreadableSecrets() {
guard !unreadableSecrets.isEmpty else { return }
var resetWhileLocked = false
for (account, keyPath) in SettingsSecrets.fields where unreadableSecrets.contains(account) {
switch SettingsSecrets.read(account) {
case .unreadable:
continue
case .value(let stored):
storedSecrets[account] = stored
if secretsToClear.contains(account) {
resetWhileLocked = true // the save below deletes it
} else if settings[keyPath: keyPath].isEmpty {
settings[keyPath: keyPath] = stored
}
case .none:
break
}
unreadableSecrets.remove(account)
secretsToClear.remove(account)
}
print("[SettingsManager] Reloaded Keychain secrets; \(unreadableSecrets.count) still unreadable")
if resetWhileLocked { scheduleSave() }
}

// MARK: - Public Methods
Expand All @@ -57,6 +128,7 @@ final class SettingsManager: ObservableObject {

/// Reset settings to defaults
func resetToDefaults() {
secretsToClear = unreadableSecrets
settings = AppSettings()
saveNow()
}
Expand Down Expand Up @@ -96,17 +168,46 @@ final class SettingsManager: ObservableObject {
}

private func performSave() {
saveSecrets()
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(settings)
// A secret typed while the Keychain was unreadable stays in the file until it isn't.
let pending = unreadableSecrets.filter { account in
SettingsSecrets.fields.contains { $0.account == account && !settings[keyPath: $0.keyPath].isEmpty }
}
let data = try encoder.encode(SettingsSecrets.forFile(settings, keepInFile: secretsKeptInFile.union(pending)))
try data.write(to: settingsURL, options: .atomic)
print("[SettingsManager] Settings saved")
} catch {
print("[SettingsManager] Error saving settings: \(error)")
}
}

/// Write the secrets that changed to the Keychain.
private func saveSecrets() {
// Try again first: the phone may have been unlocked since launch.
reloadUnreadableSecrets()
for (account, keyPath) in SettingsSecrets.fields where !unreadableSecrets.contains(account) {
let value = settings[keyPath: keyPath]
let retryDelete = value.isEmpty && pendingDeletions.contains(account)
guard storedSecrets[account, default: ""] != value || retryDelete else {
// The Keychain already has it (e.g. changed back after a failed write).
secretsKeptInFile.remove(account)
continue
}
if SettingsSecrets.write(value, account: account) {
storedSecrets[account] = value
secretsKeptInFile.remove(account)
if pendingDeletions.contains(account) { pendingDeletions.remove(account) }
} else if value.isEmpty {
pendingDeletions.insert(account) // retried, and never loaded back meanwhile
} else {
secretsKeptInFile.insert(account)
}
}
}

private static func loadSettings(from url: URL) -> AppSettings {
guard FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else {
Expand Down
109 changes: 109 additions & 0 deletions OpenVision/Managers/SettingsSecrets.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// OpenVision - SettingsSecrets.swift
// API keys and tokens from AppSettings, kept in the Keychain instead of settings.json.
//
// AppSettings still holds them in memory, so the views and services read and write them as before.
// Only persistence changes: SettingsManager writes them here and saves settings.json with those
// fields blank. A settings.json from an older build still has them in plain text; loading it
// moves them here and the next save blanks them in the file.

import Foundation
import Security

enum SettingsSecrets {

/// Every secret field, by its Keychain account.
static let fields: [(account: String, keyPath: WritableKeyPath<AppSettings, String>)] = [
("openClawAuthToken", \.openClawAuthToken),
("geminiAPIKey", \.geminiAPIKey),
("openAIAPIKey", \.openAIAPIKey),
("grokAPIKey", \.grokAPIKey),
("hermesAPIKey", \.hermesAPIKey),
("tavilyAPIKey", \.tavilyAPIKey),
("telemetryToken", \.telemetryToken),
("telemetryPassword", \.telemetryPassword),
]

/// What the Keychain said about one account: a value, nothing stored, or no answer (it can't
/// be read before the phone's first unlock).
enum Stored: Equatable {
case value(String)
case none
case unreadable
}

/// The settings to use after loading: a value still in the file (an older build's) wins, since
/// it's what the user last set; otherwise the Keychain's. Also returns the accounts that
/// couldn't be read, which mustn't be overwritten or deleted until they can.
static func merge(file: AppSettings, keychain: [String: Stored]) -> (settings: AppSettings, unreadable: Set<String>) {
var settings = file
var unreadable: Set<String> = []
for (account, keyPath) in fields where file[keyPath: keyPath].isEmpty {
switch keychain[account] ?? .none {
case .value(let value): settings[keyPath: keyPath] = value
case .none: break
case .unreadable: unreadable.insert(account)
}
}
return (settings, unreadable)
}

/// The settings as written to settings.json: secrets blank, except any whose Keychain write
/// failed, which stay in the file rather than be lost.
static func forFile(_ settings: AppSettings, keepInFile: Set<String>) -> AppSettings {
var stripped = settings
for (account, keyPath) in fields where !keepInFile.contains(account) {
stripped[keyPath: keyPath] = ""
}
return stripped
}

// MARK: - Keychain

private static let service = (Bundle.main.bundleIdentifier ?? "openvision") + ".settings"

private static func baseQuery(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account]
}

static func read(_ account: String) -> Stored {
var query = baseQuery(account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data, let value = String(data: data, encoding: .utf8) else { return .none }
return value.isEmpty ? .none : .value(value)
case errSecItemNotFound:
return .none
default:
NSLog("[Secrets] keychain read for %@ failed: %d", account, status)
return .unreadable
}
}

/// Store `value`, or delete the item when it's empty. Returns whether the Keychain took it.
@discardableResult
static func write(_ value: String, account: String) -> Bool {
if value.isEmpty {
let status = SecItemDelete(baseQuery(account) as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
// Accessible after first unlock, so replies still work in a pocket. Not "this device only"
// (unlike the sign-in tokens): pasted API keys travel with an encrypted backup or Quick
// Start to a new phone, as they did when they lived in settings.json.
let attributes: [String: Any] = [
kSecValueData as String: Data(value.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
var status = SecItemUpdate(baseQuery(account) as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
status = SecItemAdd(baseQuery(account).merging(attributes) { $1 } as CFDictionary, nil)
}
if status != errSecSuccess { NSLog("[Secrets] keychain save failed for %@: %d", account, status) }
return status == errSecSuccess
}
}
2 changes: 1 addition & 1 deletion OpenVision/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ enum TTSEngineType: String, Codable, CaseIterable, Identifiable {
}
}

/// App settings persisted to Documents/settings.json
/// App settings persisted to Documents/settings.json (API keys and tokens go to the Keychain; see SettingsSecrets)
struct AppSettings: Codable, Equatable {
// MARK: - AI Backend Selection

Expand Down
Loading
Loading