Skip to content
Open
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
4 changes: 4 additions & 0 deletions samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
9DD6CAAF2F3C426600ED7098 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CAAD2F3C426600ED7098 /* Secrets.swift */; };
9DD894B22F4047630090B9B9 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894AF2F4047630090B9B9 /* SettingsManager.swift */; };
9DD894B32F4047630090B9B9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894B02F4047630090B9B9 /* SettingsView.swift */; };
9DD894B52F4047630090B9B9 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894B42F4047630090B9B9 /* KeychainManager.swift */; };
9DD894C42F4047630090B9C4 /* ConnectedAppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894C32F4047630090B9C3 /* ConnectedAppsView.swift */; };
9DD894C22F4047630090B9C2 /* RecentTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894C12F4047630090B9C1 /* RecentTasksView.swift */; };
A1B2C3D42F0A000200000001 /* GeminiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000001 /* GeminiConfig.swift */; };
Expand Down Expand Up @@ -91,6 +92,7 @@
9DD6CAAE2F3C426600ED7098 /* Secrets.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.swift.example; sourceTree = "<group>"; };
9DD894AF2F4047630090B9B9 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
9DD894B02F4047630090B9B9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
9DD894B42F4047630090B9B9 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
9DD894C32F4047630090B9C3 /* ConnectedAppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectedAppsView.swift; sourceTree = "<group>"; };
9DD894C12F4047630090B9C1 /* RecentTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentTasksView.swift; sourceTree = "<group>"; };
A1B2C3D42F0A000100000001 /* GeminiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiConfig.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -222,6 +224,7 @@
isa = PBXGroup;
children = (
9DD894AF2F4047630090B9B9 /* SettingsManager.swift */,
9DD894B42F4047630090B9B9 /* KeychainManager.swift */,
9DD894B02F4047630090B9B9 /* SettingsView.swift */,
9DD894C32F4047630090B9C3 /* ConnectedAppsView.swift */,
9DD894C12F4047630090B9C1 /* RecentTasksView.swift */,
Expand Down Expand Up @@ -384,6 +387,7 @@
E6FD3BCE2EB4D53A00E7FE5D /* NonStreamView.swift in Sources */,
A1B2C3D42F0A000200000001 /* GeminiConfig.swift in Sources */,
9DD894B22F4047630090B9B9 /* SettingsManager.swift in Sources */,
9DD894B52F4047630090B9B9 /* KeychainManager.swift in Sources */,
9DD894B32F4047630090B9B9 /* SettingsView.swift in Sources */,
9DD894C42F4047630090B9C4 /* ConnectedAppsView.swift in Sources */,
9DD894C22F4047630090B9C2 /* RecentTasksView.swift in Sources */,
Expand Down
62 changes: 62 additions & 0 deletions samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import Security

/// Thin wrapper around the iOS Keychain for storing sensitive strings (API keys, tokens).
/// Replaces UserDefaults for secret storage — UserDefaults is unencrypted and readable
/// from device backups.
enum KeychainManager {
private static let service = "com.visionclaw.secrets"

static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}

@discardableResult
static func set(_ key: String, value: String) -> Bool {
let data = Data(value.utf8)

// Try update first (cheaper than delete+add)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let update: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)

if updateStatus == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData as String] = data
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
}

return updateStatus == errSecSuccess
}

static func delete(_ key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}

static func deleteAll() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
SecItemDelete(query as CFDictionary)
}
}
51 changes: 36 additions & 15 deletions samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,18 @@ final class SettingsManager {

private let defaults = UserDefaults.standard

private enum Key: String {
private enum SecretKey: String, CaseIterable {
case geminiAPIKey
case openClawHookToken
case openClawGatewayToken
case cloudGatewayToken
}

private enum Key: String {
case agentBackend
case openClawHost
case openClawPort
case openClawHookToken
case openClawGatewayToken
case cloudGatewayURL
case cloudGatewayToken
case accountEmail
case accountStatus
case geminiSystemPrompt
Expand All @@ -67,13 +70,30 @@ final class SettingsManager {
case proactiveNotificationsEnabled
}

private init() {}
private init() {
migrateSecretsFromUserDefaults()
}

private func migrateSecretsFromUserDefaults() {
let migrationKey = "secrets_migrated_to_keychain"
guard !defaults.bool(forKey: migrationKey) else { return }

for key in SecretKey.allCases {
if let value = defaults.string(forKey: key.rawValue), !value.isEmpty {
if KeychainManager.set(key.rawValue, value: value) {
defaults.removeObject(forKey: key.rawValue)
}
}
}

defaults.set(true, forKey: migrationKey)
}

// MARK: - Gemini

var geminiAPIKey: String {
get { defaults.string(forKey: Key.geminiAPIKey.rawValue) ?? Secrets.geminiAPIKey }
set { defaults.set(newValue, forKey: Key.geminiAPIKey.rawValue) }
get { KeychainManager.get(SecretKey.geminiAPIKey.rawValue) ?? Secrets.geminiAPIKey }
set { KeychainManager.set(SecretKey.geminiAPIKey.rawValue, value: newValue) }
}

var geminiSystemPrompt: String {
Expand All @@ -97,13 +117,13 @@ final class SettingsManager {
}

var openClawHookToken: String {
get { defaults.string(forKey: Key.openClawHookToken.rawValue) ?? Secrets.openClawHookToken }
set { defaults.set(newValue, forKey: Key.openClawHookToken.rawValue) }
get { KeychainManager.get(SecretKey.openClawHookToken.rawValue) ?? Secrets.openClawHookToken }
set { KeychainManager.set(SecretKey.openClawHookToken.rawValue, value: newValue) }
}

var openClawGatewayToken: String {
get { defaults.string(forKey: Key.openClawGatewayToken.rawValue) ?? Secrets.openClawGatewayToken }
set { defaults.set(newValue, forKey: Key.openClawGatewayToken.rawValue) }
get { KeychainManager.get(SecretKey.openClawGatewayToken.rawValue) ?? Secrets.openClawGatewayToken }
set { KeychainManager.set(SecretKey.openClawGatewayToken.rawValue, value: newValue) }
}

// MARK: - Agent backend selection
Expand Down Expand Up @@ -151,8 +171,8 @@ final class SettingsManager {
}

var cloudGatewayToken: String {
get { defaults.string(forKey: Key.cloudGatewayToken.rawValue) ?? Secrets.cloudGatewayToken }
set { defaults.set(newValue, forKey: Key.cloudGatewayToken.rawValue) }
get { KeychainManager.get(SecretKey.cloudGatewayToken.rawValue) ?? Secrets.cloudGatewayToken }
set { KeychainManager.set(SecretKey.cloudGatewayToken.rawValue, value: newValue) }
}

// MARK: - Account (Google sign-in)
Expand Down Expand Up @@ -193,8 +213,9 @@ final class SettingsManager {
// MARK: - Reset

func resetAll() {
for key in [Key.geminiAPIKey, .geminiSystemPrompt, .agentBackend, .openClawHost, .openClawPort,
.openClawHookToken, .openClawGatewayToken, .cloudGatewayURL, .cloudGatewayToken,
KeychainManager.deleteAll()
for key in [Key.geminiSystemPrompt, .agentBackend, .openClawHost, .openClawPort,
.cloudGatewayURL,
.accountEmail, .accountStatus,
.speakerOutputEnabled, .videoStreamingEnabled,
.proactiveNotificationsEnabled] {
Expand Down