diff --git a/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj b/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj index 0a714ae8..e2264fb3 100644 --- a/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj +++ b/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj @@ -27,19 +27,22 @@ 8FFD60542E849D0D0035E446 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FFD60532E849D0D0035E446 /* RegistrationView.swift */; }; 8FFD60602E84A2F70035E446 /* MainAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FFD605F2E84A2F70035E446 /* MainAppView.swift */; }; 8FFD60612E84A2F70035E446 /* DebugMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FFD605E2E84A2F70035E446 /* DebugMenuView.swift */; }; - 9DD6CAAF2F3C426600ED7098 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CAAD2F3C426600ED7098 /* Secrets.swift */; }; - A1B2C3D42F0A000200000001 /* GeminiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000001 /* GeminiConfig.swift */; }; A1B2C3D42F0A000200000002 /* GeminiLiveService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000002 /* GeminiLiveService.swift */; }; A1B2C3D42F0A000200000003 /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000003 /* AudioManager.swift */; }; A1B2C3D42F0A000200000004 /* GeminiSessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000004 /* GeminiSessionViewModel.swift */; }; A1B2C3D42F0A000200000005 /* GeminiOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000005 /* GeminiOverlayView.swift */; }; + E0AAD7C02F3D109200015D5A /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0AAD7BF2F3D109200015D5A /* Secrets.swift */; }; E66D30242E7DA71900470B48 /* MockDeviceKitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66D30232E7DA71900470B48 /* MockDeviceKitButton.swift */; }; E6A188482EB918740097D0E1 /* StreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A188472EB918740097D0E1 /* StreamView.swift */; }; E6DA451D2E79A63100E3F688 /* MockDeviceCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DA45182E79A63100E3F688 /* MockDeviceCardView.swift */; }; E6DA451E2E79A63100E3F688 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DA45172E79A63100E3F688 /* CardView.swift */; }; E6DA451F2E79A63100E3F688 /* MockDeviceKitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DA451A2E79A63100E3F688 /* MockDeviceKitView.swift */; }; E6FD3BCE2EB4D53A00E7FE5D /* NonStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FD3BCD2EB4D53A00E7FE5D /* NonStreamView.swift */; }; + E6FD3BD02EB4D53A00E7FE5E /* SecretsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FD3BCF2EB4D53A00E7FE5E /* SecretsManager.swift */; }; + E6FD3BD22EB4D53A00E7FE5F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FD3BD12EB4D53A00E7FE5F /* SettingsView.swift */; }; + E6FD3BD42EB4D53A00E7FE63 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FD3BD32EB4D53A00E7FE63 /* AuthManager.swift */; }; + E6FD3BD62EB4D53A00E7FE64 /* SecretsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FD3BD52EB4D53A00E7FE64 /* SecretsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -86,13 +89,13 @@ 8FFD60532E849D0D0035E446 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; 8FFD605E2E84A2F70035E446 /* DebugMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugMenuView.swift; sourceTree = ""; }; 8FFD605F2E84A2F70035E446 /* MainAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppView.swift; sourceTree = ""; }; - 9DD6CAAD2F3C426600ED7098 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; 9DD6CAAE2F3C426600ED7098 /* Secrets.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.swift.example; sourceTree = ""; }; A1B2C3D42F0A000100000001 /* GeminiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiConfig.swift; sourceTree = ""; }; A1B2C3D42F0A000100000002 /* GeminiLiveService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiLiveService.swift; sourceTree = ""; }; A1B2C3D42F0A000100000003 /* AudioManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManager.swift; sourceTree = ""; }; A1B2C3D42F0A000100000004 /* GeminiSessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiSessionViewModel.swift; sourceTree = ""; }; A1B2C3D42F0A000100000005 /* GeminiOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiOverlayView.swift; sourceTree = ""; }; + E0AAD7BF2F3D109200015D5A /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; E66D30232E7DA71900470B48 /* MockDeviceKitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeviceKitButton.swift; sourceTree = ""; }; E699CC952E8150670052C240 /* CameraAccessTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CameraAccessTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E6A188472EB918740097D0E1 /* StreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamView.swift; sourceTree = ""; }; @@ -100,6 +103,10 @@ E6DA45182E79A63100E3F688 /* MockDeviceCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeviceCardView.swift; sourceTree = ""; }; E6DA451A2E79A63100E3F688 /* MockDeviceKitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeviceKitView.swift; sourceTree = ""; }; E6FD3BCD2EB4D53A00E7FE5D /* NonStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonStreamView.swift; sourceTree = ""; }; + E6FD3BCF2EB4D53A00E7FE5E /* SecretsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretsManager.swift; sourceTree = ""; }; + E6FD3BD12EB4D53A00E7FE5F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + E6FD3BD32EB4D53A00E7FE63 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + E6FD3BD52EB4D53A00E7FE64 /* SecretsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -172,6 +179,8 @@ 8FD96B752E6F0A9800F56AB1 /* StreamSessionView.swift */, E6A188472EB918740097D0E1 /* StreamView.swift */, E6FD3BCD2EB4D53A00E7FE5D /* NonStreamView.swift */, + E6FD3BD12EB4D53A00E7FE5F /* SettingsView.swift */, + E6FD3BD52EB4D53A00E7FE64 /* SecretsView.swift */, ); path = Views; sourceTree = ""; @@ -179,7 +188,7 @@ 8FD96B7D2E6F0A9800F56AB1 /* CameraAccess */ = { isa = PBXGroup; children = ( - 9DD6CAAD2F3C426600ED7098 /* Secrets.swift */, + E0AAD7BF2F3D109200015D5A /* Secrets.swift */, 9DD6CAAE2F3C426600ED7098 /* Secrets.swift.example */, 9D3C69602F367CF700E641A5 /* iPhone */, A1B2C3D42F0A000300000001 /* Gemini */, @@ -223,6 +232,8 @@ A1B2C3D42F0A000100000001 /* GeminiConfig.swift */, A1B2C3D42F0A000100000002 /* GeminiLiveService.swift */, A1B2C3D42F0A000100000004 /* GeminiSessionViewModel.swift */, + E6FD3BD32EB4D53A00E7FE63 /* AuthManager.swift */, + E6FD3BCF2EB4D53A00E7FE5E /* SecretsManager.swift */, ); path = Gemini; sourceTree = ""; @@ -348,11 +359,10 @@ buildActionMask = 2147483647; files = ( 8FD96B7F2E6F0A9800F56AB1 /* CameraAccessApp.swift in Sources */, + E0AAD7C02F3D109200015D5A /* Secrets.swift in Sources */, 8FD96B812E6F0A9800F56AB1 /* HomeScreenView.swift in Sources */, 8F2D23802E856711002D0588 /* DebugMenuViewModel.swift in Sources */, 8FFD60542E849D0D0035E446 /* RegistrationView.swift in Sources */, - 9DD6CAAF2F3C426600ED7098 /* Secrets.swift in Sources */, - 8FFD60342E8434070035E446 /* MediaPickerView.swift in Sources */, 8FFD60352E8434070035E446 /* StatusText.swift in Sources */, 8FFD602C2E8433E20035E446 /* MockDeviceKitViewModel.swift in Sources */, @@ -371,6 +381,10 @@ 8FFD5FF52E8422580035E446 /* CircleButton.swift in Sources */, 8FFD5FF62E8422580035E446 /* CustomButton.swift in Sources */, E6FD3BCE2EB4D53A00E7FE5D /* NonStreamView.swift in Sources */, + E6FD3BD02EB4D53A00E7FE5E /* SecretsManager.swift in Sources */, + E6FD3BD22EB4D53A00E7FE5F /* SettingsView.swift in Sources */, + E6FD3BD42EB4D53A00E7FE63 /* AuthManager.swift in Sources */, + E6FD3BD62EB4D53A00E7FE64 /* SecretsView.swift in Sources */, A1B2C3D42F0A000200000001 /* GeminiConfig.swift in Sources */, A1B2C3D42F0A000200000002 /* GeminiLiveService.swift in Sources */, A1B2C3D42F0A000200000003 /* AudioManager.swift in Sources */, @@ -408,7 +422,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = WY253UX7FC; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = CameraAccess/Info.plist; @@ -437,7 +451,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = WY253UX7FC; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = CameraAccess/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; diff --git a/samples/CameraAccess/CameraAccess/Gemini/AuthManager.swift b/samples/CameraAccess/CameraAccess/Gemini/AuthManager.swift new file mode 100644 index 00000000..8b796652 --- /dev/null +++ b/samples/CameraAccess/CameraAccess/Gemini/AuthManager.swift @@ -0,0 +1,140 @@ +// +// AuthManager.swift +// CameraAccess +// +// Manages PIN and Face ID authentication for the Secrets view. +// PIN is stored hashed in Keychain. Face ID preference in UserDefaults. +// + +import Foundation +import LocalAuthentication +import Security + +final class AuthManager: ObservableObject { + static let shared = AuthManager() + + private enum Keys { + static let pinHash = "com.cameraaccess.secrets.pinHash" + static let useFaceID = "secrets.useFaceID" + } + + private let defaults = UserDefaults.standard + + @Published var isUnlocked = false + @Published var useFaceID: Bool { + didSet { defaults.set(useFaceID, forKey: Keys.useFaceID) } + } + + var hasPIN: Bool { + KeychainHelper.load(key: Keys.pinHash) != nil + } + + var isFaceIDAvailable: Bool { + let context = LAContext() + var error: NSError? + return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + } + + private init() { + self.useFaceID = defaults.object(forKey: Keys.useFaceID) as? Bool ?? true + } + + func setPIN(_ pin: String) -> Bool { + guard pin.count >= 4 else { return false } + let hash = pin.sha256Hash + return KeychainHelper.save(key: Keys.pinHash, value: hash) + } + + func verifyPIN(_ pin: String) -> Bool { + guard let stored = KeychainHelper.load(key: Keys.pinHash) else { return false } + return pin.sha256Hash == stored + } + + func authenticateWithFaceID(reason: String = "Unlock secrets") async -> Bool { + let context = LAContext() + var error: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + return false + } + + do { + let success = try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: reason + ) + await MainActor.run { isUnlocked = success } + return success + } catch { + return false + } + } + + func unlock(withPIN pin: String) -> Bool { + let ok = verifyPIN(pin) + if ok { isUnlocked = true } + return ok + } + + func lock() { + isUnlocked = false + } + + func removePIN() { + KeychainHelper.delete(key: Keys.pinHash) + useFaceID = false + isUnlocked = false + } +} + +// MARK: - Keychain Helper + +private enum KeychainHelper { + static func save(key: String, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + SecItemDelete(query as CFDictionary) // Remove existing + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let string = String(data: data, encoding: .utf8) else { return nil } + return string + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - SHA256 Hash + +private extension String { + var sha256Hash: String { + guard let data = data(using: .utf8) else { return "" } + let hash = SHA256.hash(data: data) + return hash.map { String(format: "%02x", $0) }.joined() + } +} + +import CryptoKit diff --git a/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift b/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift index be2e703b..a4c266e3 100644 --- a/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift +++ b/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift @@ -40,13 +40,12 @@ enum GeminiConfig { For messages, confirm recipient and content before delegating unless clearly urgent. """ - // Secrets are stored in Secrets.swift (gitignored). - // Copy Secrets.example.swift -> Secrets.swift and fill in your values. - static let apiKey = Secrets.geminiAPIKey - static let openClawHost = Secrets.openClawHost - static let openClawPort = Secrets.openClawPort - static let openClawHookToken = Secrets.openClawHookToken - static let openClawGatewayToken = Secrets.openClawGatewayToken + // Secrets are loaded from SecretsManager (device storage). Configure via Settings screen. + static var apiKey: String { SecretsManager.shared.geminiAPIKey } + static var openClawHost: String { SecretsManager.shared.openClawHost } + static var openClawPort: Int { SecretsManager.shared.openClawPort } + static var openClawHookToken: String { SecretsManager.shared.openClawHookToken } + static var openClawGatewayToken: String { SecretsManager.shared.openClawGatewayToken } static func websocketURL() -> URL? { guard apiKey != "YOUR_GEMINI_API_KEY" && !apiKey.isEmpty else { return nil } diff --git a/samples/CameraAccess/CameraAccess/Gemini/SecretsManager.swift b/samples/CameraAccess/CameraAccess/Gemini/SecretsManager.swift new file mode 100644 index 00000000..b8abfd56 --- /dev/null +++ b/samples/CameraAccess/CameraAccess/Gemini/SecretsManager.swift @@ -0,0 +1,66 @@ +// +// SecretsManager.swift +// CameraAccess +// +// Manages app secrets with device storage. Falls back to Secrets.swift defaults +// when no stored values exist. Values are persisted in UserDefaults. +// + +import Combine +import Foundation + +class SecretsManager: ObservableObject { + static let shared = SecretsManager() + + private enum Keys { + static let geminiAPIKey = "secrets.geminiAPIKey" + static let openClawHost = "secrets.openClawHost" + static let openClawPort = "secrets.openClawPort" + static let openClawHookToken = "secrets.openClawHookToken" + static let openClawGatewayToken = "secrets.openClawGatewayToken" + static let hasStoredValues = "secrets.hasStoredValues" + } + + private let defaults = UserDefaults.standard + + @Published var geminiAPIKey: String + @Published var openClawHost: String + @Published var openClawPort: Int + @Published var openClawHookToken: String + @Published var openClawGatewayToken: String + + private init() { + if defaults.bool(forKey: Keys.hasStoredValues) { + self.geminiAPIKey = defaults.string(forKey: Keys.geminiAPIKey) ?? Secrets.geminiAPIKey + self.openClawHost = defaults.string(forKey: Keys.openClawHost) ?? Secrets.openClawHost + self.openClawPort = defaults.object(forKey: Keys.openClawPort) as? Int ?? Secrets.openClawPort + self.openClawHookToken = defaults.string(forKey: Keys.openClawHookToken) ?? Secrets.openClawHookToken + self.openClawGatewayToken = defaults.string(forKey: Keys.openClawGatewayToken) ?? Secrets.openClawGatewayToken + } else { + self.geminiAPIKey = Secrets.geminiAPIKey + self.openClawHost = Secrets.openClawHost + self.openClawPort = Secrets.openClawPort + self.openClawHookToken = Secrets.openClawHookToken + self.openClawGatewayToken = Secrets.openClawGatewayToken + save() + } + } + + func save() { + defaults.set(true, forKey: Keys.hasStoredValues) + defaults.set(geminiAPIKey, forKey: Keys.geminiAPIKey) + defaults.set(openClawHost, forKey: Keys.openClawHost) + defaults.set(openClawPort, forKey: Keys.openClawPort) + defaults.set(openClawHookToken, forKey: Keys.openClawHookToken) + defaults.set(openClawGatewayToken, forKey: Keys.openClawGatewayToken) + } + + func resetToDefaults() { + geminiAPIKey = Secrets.geminiAPIKey + openClawHost = Secrets.openClawHost + openClawPort = Secrets.openClawPort + openClawHookToken = Secrets.openClawHookToken + openClawGatewayToken = Secrets.openClawGatewayToken + save() + } +} diff --git a/samples/CameraAccess/CameraAccess/Info.plist b/samples/CameraAccess/CameraAccess/Info.plist index 54e867f7..b5bcd5e2 100644 --- a/samples/CameraAccess/CameraAccess/Info.plist +++ b/samples/CameraAccess/CameraAccess/Info.plist @@ -57,6 +57,8 @@ NSBluetoothAlwaysUsageDescription Needed to connect to Meta AI Glasses + NSFaceIDUsageDescription + Use Face ID to unlock your secrets instead of entering your PIN. UISupportedExternalAccessoryProtocols com.meta.ar.wearable diff --git a/samples/CameraAccess/CameraAccess/Views/HomeScreenView.swift b/samples/CameraAccess/CameraAccess/Views/HomeScreenView.swift index 6733bcb0..8d7c372d 100644 --- a/samples/CameraAccess/CameraAccess/Views/HomeScreenView.swift +++ b/samples/CameraAccess/CameraAccess/Views/HomeScreenView.swift @@ -18,12 +18,26 @@ import SwiftUI struct HomeScreenView: View { @ObservedObject var viewModel: WearablesViewModel + @State private var showSettings = false var body: some View { ZStack { Color.white.edgesIgnoringSafeArea(.all) VStack(spacing: 12) { + HStack { + Spacer() + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.black) + .frame(width: 24, height: 24) + } + } + Spacer() Image(.cameraAccessIcon) @@ -70,6 +84,9 @@ struct HomeScreenView: View { } .padding(.all, 24) } + .sheet(isPresented: $showSettings) { + SettingsView(wearablesVM: viewModel) + } } } diff --git a/samples/CameraAccess/CameraAccess/Views/NonStreamView.swift b/samples/CameraAccess/CameraAccess/Views/NonStreamView.swift index c657b67f..c9828229 100644 --- a/samples/CameraAccess/CameraAccess/Views/NonStreamView.swift +++ b/samples/CameraAccess/CameraAccess/Views/NonStreamView.swift @@ -20,6 +20,7 @@ struct NonStreamView: View { @ObservedObject var viewModel: StreamSessionViewModel @ObservedObject var wearablesVM: WearablesViewModel @State private var sheetHeight: CGFloat = 300 + @State private var showSettings = false var body: some View { ZStack { @@ -28,11 +29,8 @@ struct NonStreamView: View { VStack { HStack { Spacer() - Menu { - Button("Disconnect", role: .destructive) { - wearablesVM.disconnectGlasses() - } - .disabled(wearablesVM.registrationState != .registered) + Button { + showSettings = true } label: { Image(systemName: "gearshape") .resizable() @@ -101,6 +99,9 @@ struct NonStreamView: View { } .padding(.all, 24) } + .sheet(isPresented: $showSettings) { + SettingsView(wearablesVM: wearablesVM) + } .sheet(isPresented: $wearablesVM.showGettingStartedSheet) { if #available(iOS 16.0, *) { GettingStartedSheetView(height: $sheetHeight) diff --git a/samples/CameraAccess/CameraAccess/Views/SecretsView.swift b/samples/CameraAccess/CameraAccess/Views/SecretsView.swift new file mode 100644 index 00000000..5fa59283 --- /dev/null +++ b/samples/CameraAccess/CameraAccess/Views/SecretsView.swift @@ -0,0 +1,418 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + */ + +// +// SecretsView.swift +// +// Password-protected view for configuring API keys and tokens. +// Unlock via PIN or Face ID (when enabled). +// + +import SwiftUI + +struct SecretsView: View { + @ObservedObject var authManager = AuthManager.shared + @ObservedObject private var secretsManager = SecretsManager.shared + @Environment(\.dismiss) var dismiss + @State private var showSaveConfirmation = false + @State private var showChangePIN = false + + var body: some View { + Group { + if !authManager.hasPIN { + PINSetupView() + } else if !authManager.isUnlocked { + PINUnlockView() + } else { + secretsContent + } + } + .onDisappear { + authManager.lock() + } + } + + private var secretsContent: some View { + NavigationStack { + List { + // Security section + Section { + if authManager.isFaceIDAvailable { + Toggle("Use Face ID", isOn: $authManager.useFaceID) + } + Button("Change PIN") { + showChangePIN = true + } + } header: { + Text("Security") + } footer: { + if authManager.isFaceIDAvailable { + Text("When enabled, you can unlock secrets with Face ID instead of entering your PIN.") + } + } + .sheet(isPresented: $showChangePIN) { + ChangePINView() + } + + // Configure secrets section + Section { + LabeledContent("Gemini API Key") { + TextField("API key", text: $secretsManager.geminiAPIKey) + .textContentType(.password) + .autocapitalization(.none) + .autocorrectionDisabled() + } + + LabeledContent("OpenClaw Host") { + TextField("Host URL", text: $secretsManager.openClawHost) + .textContentType(.URL) + .autocapitalization(.none) + .autocorrectionDisabled() + .keyboardType(.URL) + } + + LabeledContent("OpenClaw Port") { + TextField("Port", value: $secretsManager.openClawPort, format: IntegerFormatStyle().grouping(.never)) + .keyboardType(.numberPad) + } + + LabeledContent("OpenClaw Hook Token") { + TextField("Hook token", text: $secretsManager.openClawHookToken) + .textContentType(.password) + .autocapitalization(.none) + .autocorrectionDisabled() + } + + LabeledContent("OpenClaw Gateway Token") { + TextField("Gateway token", text: $secretsManager.openClawGatewayToken) + .textContentType(.password) + .autocapitalization(.none) + .autocorrectionDisabled() + } + + Button("Reset to defaults") { + secretsManager.resetToDefaults() + showSaveConfirmation = true + } + .foregroundColor(.orange) + } header: { + Text("Configure Secrets") + } footer: { + Text("Gemini API key from aistudio.google.com/apikey. OpenClaw values from your Mac gateway. Stored on device.") + } + + // Save button + Section { + Button("Save") { + secretsManager.save() + showSaveConfirmation = true + } + .frame(maxWidth: .infinity) + .fontWeight(.semibold) + } + } + .navigationTitle("Secrets") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + .alert("Saved", isPresented: $showSaveConfirmation) { + Button("OK") { } + } message: { + Text("Your settings have been saved.") + } + } + } +} + +// MARK: - PIN Setup (first-time) + +private struct PINSetupView: View { + @ObservedObject var authManager = AuthManager.shared + @State private var pin = "" + @State private var confirmPin = "" + @State private var step: Step = .create + @State private var errorMessage: String? + @FocusState private var focusedField: Field? + + private enum Step { + case create + case confirm + } + + private enum Field { + case pin + case confirm + } + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Image(systemName: "lock.shield") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text(step == .create ? "Create PIN" : "Confirm PIN") + .font(.title2) + .fontWeight(.semibold) + + Text("Enter a 4–6 digit PIN to protect your secrets.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if let message = errorMessage { + Text(message) + .font(.caption) + .foregroundColor(.red) + } + + SecureField(step == .create ? "PIN" : "Confirm PIN", text: step == .create ? $pin : $confirmPin) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .focused($focusedField, equals: step == .create ? .pin : .confirm) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.horizontal, 32) + + if step == .create { + Button("Continue") { + guard pin.count >= 4, pin.count <= 6 else { + errorMessage = "PIN must be 4–6 digits" + return + } + errorMessage = nil + step = .confirm + focusedField = .confirm + } + .buttonStyle(.borderedProminent) + .disabled(pin.count < 4 || pin.count > 6) + } else { + Button("Set PIN") { + guard pin == confirmPin else { + errorMessage = "PINs do not match" + return + } + if authManager.setPIN(pin) { + authManager.isUnlocked = true + } else { + errorMessage = "Could not save PIN" + } + } + .buttonStyle(.borderedProminent) + .disabled(pin != confirmPin || confirmPin.isEmpty) + } + } + .navigationTitle("Secrets") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +// MARK: - PIN Unlock + +private struct PINUnlockView: View { + @ObservedObject var authManager = AuthManager.shared + @State private var pin = "" + @State private var errorMessage: String? + @FocusState private var isPinFocused: Bool + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Image(systemName: "lock.fill") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text("Enter PIN") + .font(.title2) + .fontWeight(.semibold) + + Text("Enter your PIN to unlock secrets.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if let message = errorMessage { + Text(message) + .font(.caption) + .foregroundColor(.red) + } + + SecureField("PIN", text: $pin) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .focused($isPinFocused) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.horizontal, 32) + + if authManager.useFaceID && authManager.isFaceIDAvailable { + Button { + Task { + let success = await authManager.authenticateWithFaceID(reason: "Unlock secrets") + if !success { + errorMessage = "Face ID failed" + } + } + } label: { + Label("Face ID", systemImage: "faceid") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 32) + } + + Button("Unlock") { + if authManager.unlock(withPIN: pin) { + errorMessage = nil + } else { + errorMessage = "Incorrect PIN" + } + } + .buttonStyle(.borderedProminent) + .disabled(pin.count < 4) + .padding(.horizontal, 32) + } + .navigationTitle("Secrets") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: pin) { _, newValue in + if newValue.count >= 4, authManager.verifyPIN(newValue) { + authManager.unlock(withPIN: newValue) + } + } + } + } +} + +// MARK: - Change PIN + +private struct ChangePINView: View { + @ObservedObject var authManager = AuthManager.shared + @Environment(\.dismiss) var dismiss + @State private var currentPin = "" + @State private var newPin = "" + @State private var confirmPin = "" + @State private var step: Step = .current + @State private var errorMessage: String? + + private enum Step { + case current + case new + case confirm + } + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Text(stepTitle) + .font(.title2) + .fontWeight(.semibold) + + if step == .current { + SecureField("Current PIN", text: $currentPin) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.horizontal, 32) + } else if step == .new { + SecureField("New PIN", text: $newPin) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.horizontal, 32) + } else { + SecureField("Confirm new PIN", text: $confirmPin) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.horizontal, 32) + } + + if let message = errorMessage { + Text(message) + .font(.caption) + .foregroundColor(.red) + } + + if step == .current { + Button("Continue") { + guard authManager.verifyPIN(currentPin) else { + errorMessage = "Incorrect PIN" + return + } + errorMessage = nil + step = .new + } + .buttonStyle(.borderedProminent) + .disabled(currentPin.count < 4) + } else if step == .new { + Button("Continue") { + guard newPin.count >= 4, newPin.count <= 6 else { + errorMessage = "PIN must be 4–6 digits" + return + } + errorMessage = nil + step = .confirm + } + .buttonStyle(.borderedProminent) + .disabled(newPin.count < 4 || newPin.count > 6) + } else { + Button("Update PIN") { + guard newPin == confirmPin else { + errorMessage = "PINs do not match" + return + } + if authManager.setPIN(newPin) { + dismiss() + } else { + errorMessage = "Could not save PIN" + } + } + .buttonStyle(.borderedProminent) + .disabled(newPin != confirmPin || confirmPin.isEmpty) + } + } + .navigationTitle("Change PIN") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } + + private var stepTitle: String { + switch step { + case .current: "Enter current PIN" + case .new: "Enter new PIN" + case .confirm: "Confirm new PIN" + } + } +} + +#Preview { + SecretsView() +} diff --git a/samples/CameraAccess/CameraAccess/Views/SettingsView.swift b/samples/CameraAccess/CameraAccess/Views/SettingsView.swift new file mode 100644 index 00000000..d8c297cd --- /dev/null +++ b/samples/CameraAccess/CameraAccess/Views/SettingsView.swift @@ -0,0 +1,67 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + */ + +// +// SettingsView.swift +// +// Settings screen with disconnect option and configure secrets form. +// Secrets are stored on device and can be edited without recompiling. +// + +import MWDATCore +import SwiftUI + +struct SettingsView: View { + @ObservedObject var wearablesVM: WearablesViewModel + @Environment(\.dismiss) var dismiss + @State private var showSecrets = false + + var body: some View { + NavigationStack { + List { + // Disconnect section + Section { + Button("Disconnect", role: .destructive) { + wearablesVM.disconnectGlasses() + dismiss() + } + .disabled(wearablesVM.registrationState != .registered) + } header: { + Text("Connection") + } footer: { + Text("Disconnect your glasses from this app.") + } + + // Secrets section + Section { + Button { + showSecrets = true + } label: { + Label("Configure Secrets", systemImage: "key.fill") + } + } header: { + Text("API Keys & Tokens") + } footer: { + Text("Gemini API key, OpenClaw host, and tokens. Protected by PIN.") + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + .sheet(isPresented: $showSecrets) { + SecretsView() + } + } + } +}