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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ apple/.build/
apple/Packages/WayfinderRoutingBridge/.build/
apple/Packages/WayfinderRoutingBridge/Artifacts/
apple/Packages/WayfinderRoutingBridge/Sources/WayfinderRoutingBridge/Generated/

# Per-user SwiftPM and Xcode workspace state
**/.swiftpm/
**/xcuserdata/
7 changes: 5 additions & 2 deletions ios/WayfinderIOS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ Chat lifecycle through a deterministic network-free provider. Ordered deltas,
stop, interruption recovery, failure, retry, threads, drafts, terminal message
states, and compact route receipts persist locally through the
`ConversationStore` boundary and a versioned SwiftData implementation.
Credentials, live providers, Apple Foundation Models, and optional Mac pairing
land in later review boundaries.
API keys can be added, replaced, and removed through the native Settings flow.
Secrets remain in the device-only iOS Keychain; app-visible state contains only
configured/not-configured snapshots. Adding a key does not publish a
destination or change Automatic routing. Live providers, Apple Foundation
Models, and optional Mac pairing land in later review boundaries.

## Build

Expand Down
12 changes: 12 additions & 0 deletions ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@
2663961FFDA2EEAEF58529A8 /* ConversationStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A79F801F4849CBD50C8A61 /* ConversationStoreTests.swift */; };
2FDE760EEAA633A74BF8B379 /* ChatExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E92E195F328A16C8A4C9159 /* ChatExecution.swift */; };
383CBBDE76DCB1296EE2B2FF /* ChatTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */; };
505322426BA37D7C41706A05 /* CredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7B4FB8866C8CE81BA3E3A9 /* CredentialStore.swift */; };
5289F042BE903630416D2DC7 /* ThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA6AA772008E2B7C904EA80 /* ThreadsView.swift */; };
A7AFF77BAFD88E83930B3DC6 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3D621AD229ED0AF9703D04 /* AppModel.swift */; };
A95064FBE316BC583A492270 /* WayfinderRoutingBridge in Frameworks */ = {isa = PBXBuildFile; productRef = 2B873E1C9EC13707AC9AB4B1 /* WayfinderRoutingBridge */; };
AD9F05EEA94FD7440F0DD8A9 /* AppModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D8ECF221517590115EA9F /* AppModelTests.swift */; };
C04837E936D39B59FC3DAEDE /* DestinationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375A787B3245E247D326C13E /* DestinationsView.swift */; };
D060F612A4C672A8932A47B9 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B75781C7C160DEB4257207 /* APIKeysView.swift */; };
DDF893703A284EDEF6594AE6 /* WayfinderTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B27149D56F517F17D1F971B /* WayfinderTheme.swift */; };
EBF78778DA2B68EDA8347CAF /* WayfinderIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2C2884452C3AECE92EE0E1 /* WayfinderIOSApp.swift */; };
F4E9D5D8ED49654292FFA71E /* CredentialStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4110CCB21039F8900BB91 /* CredentialStoreTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand All @@ -37,9 +40,12 @@
25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTabView.swift; sourceTree = "<group>"; };
375A787B3245E247D326C13E /* DestinationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsView.swift; sourceTree = "<group>"; };
39BC65B89F3C0433A3716A07 /* WayfinderRoutingBridge */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WayfinderRoutingBridge; path = ../../apple/Packages/WayfinderRoutingBridge; sourceTree = SOURCE_ROOT; };
3AD4110CCB21039F8900BB91 /* CredentialStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialStoreTests.swift; sourceTree = "<group>"; };
4A2C2884452C3AECE92EE0E1 /* WayfinderIOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WayfinderIOSApp.swift; sourceTree = "<group>"; };
4DA6AA772008E2B7C904EA80 /* ThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadsView.swift; sourceTree = "<group>"; };
5F6D147CFBC7918BCCD92B9E /* WayfinderIOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = WayfinderIOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
64B75781C7C160DEB4257207 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = "<group>"; };
8A7B4FB8866C8CE81BA3E3A9 /* CredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialStore.swift; sourceTree = "<group>"; };
910171253EA965875F0C979A /* WayfinderIOSTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WayfinderIOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9B27149D56F517F17D1F971B /* WayfinderTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WayfinderTheme.swift; sourceTree = "<group>"; };
9E92E195F328A16C8A4C9159 /* ChatExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExecution.swift; sourceTree = "<group>"; };
Expand All @@ -65,10 +71,12 @@
0F27F14F5F65590F365CE447 /* WayfinderIOS */ = {
isa = PBXGroup;
children = (
64B75781C7C160DEB4257207 /* APIKeysView.swift */,
0D3D621AD229ED0AF9703D04 /* AppModel.swift */,
9E92E195F328A16C8A4C9159 /* ChatExecution.swift */,
25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */,
D89C6EA7388FA2CCF9F5865E /* ConversationStore.swift */,
8A7B4FB8866C8CE81BA3E3A9 /* CredentialStore.swift */,
375A787B3245E247D326C13E /* DestinationsView.swift */,
A28A0256D98D907F1BAF7A34 /* RootView.swift */,
BE0BB131143C117BDF129E06 /* SettingsView.swift */,
Expand All @@ -93,6 +101,7 @@
children = (
C17D8ECF221517590115EA9F /* AppModelTests.swift */,
D8A79F801F4849CBD50C8A61 /* ConversationStoreTests.swift */,
3AD4110CCB21039F8900BB91 /* CredentialStoreTests.swift */,
);
path = WayfinderIOSTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -199,17 +208,20 @@
files = (
AD9F05EEA94FD7440F0DD8A9 /* AppModelTests.swift in Sources */,
2663961FFDA2EEAEF58529A8 /* ConversationStoreTests.swift in Sources */,
F4E9D5D8ED49654292FFA71E /* CredentialStoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
72D843E6774C0988AA0ACB57 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D060F612A4C672A8932A47B9 /* APIKeysView.swift in Sources */,
A7AFF77BAFD88E83930B3DC6 /* AppModel.swift in Sources */,
2FDE760EEAA633A74BF8B379 /* ChatExecution.swift in Sources */,
383CBBDE76DCB1296EE2B2FF /* ChatTabView.swift in Sources */,
165C905840F4DCF56C27BF8F /* ConversationStore.swift in Sources */,
505322426BA37D7C41706A05 /* CredentialStore.swift in Sources */,
C04837E936D39B59FC3DAEDE /* DestinationsView.swift in Sources */,
014B0F0F1659B0478F68FAF3 /* RootView.swift in Sources */,
17923336D4226B2F812CF7CA /* SettingsView.swift in Sources */,
Expand Down
187 changes: 187 additions & 0 deletions ios/WayfinderIOS/WayfinderIOS/APIKeysView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import SwiftUI

struct APIKeysView: View {
@Environment(AppModel.self) private var appModel
@State private var editingProvider: APIKeyProviderDescriptor?
@State private var removalProvider: APIKeyProviderDescriptor?

var body: some View {
List {
Section {
ForEach(APIKeyProviderDescriptor.supported) { provider in
APIKeyProviderRow(
provider: provider,
isConfigured: appModel.isCredentialConfigured(provider.id),
edit: { editingProvider = provider },
remove: { removalProvider = provider }
)
}
} footer: {
Text(
"Keys remain in this device's Keychain. Adding one does not change Automatic routing or enable a provider until its destination is configured."
)
}

Section("Account access") {
LabeledContent("ChatGPT / Codex", value: "Not an API key")
Text(
"OpenAI Platform keys and ChatGPT subscriptions are separate. Native account sign-in is not part of this build."
)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("API Keys")
.sheet(item: $editingProvider) { provider in
APIKeyEditorSheet(provider: provider)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Remove API key?",
isPresented: Binding(
get: { removalProvider != nil },
set: { isPresented in
if !isPresented {
removalProvider = nil
}
}
),
titleVisibility: .visible
) {
if let provider = removalProvider {
Button("Remove \(provider.displayName) Key", role: .destructive) {
removalProvider = nil
Task {
await appModel.removeAPIKey(for: provider)
}
}
}
Button("Cancel", role: .cancel) {
removalProvider = nil
}
} message: {
Text(
"Wayfinder will delete this credential from the iOS Keychain. Existing conversations are unaffected."
)
}
.task {
await appModel.restoreCredentialStatuses()
}
}
}

private struct APIKeyProviderRow: View {
let provider: APIKeyProviderDescriptor
let isConfigured: Bool
let edit: () -> Void
let remove: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(provider.displayName)
.font(.body.weight(.medium))
Text(provider.detail)
.font(.footnote)
.foregroundStyle(.secondary)
}

Spacer(minLength: 12)

Label(
isConfigured ? "Saved" : "Not configured",
systemImage: isConfigured ? "checkmark.circle.fill" : "circle"
)
.font(.caption.weight(.semibold))
.foregroundStyle(isConfigured ? WayfinderTheme.accent : .secondary)
}

HStack {
Button(isConfigured ? "Replace Key" : "Add Key", action: edit)
.buttonStyle(.bordered)

if isConfigured {
Button("Remove", role: .destructive, action: remove)
.buttonStyle(.borderless)
}
}
}
.padding(.vertical, 5)
.accessibilityElement(children: .contain)
}
}

private struct APIKeyEditorSheet: View {
@Environment(AppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var key = ""
@State private var isSaving = false

let provider: APIKeyProviderDescriptor

var body: some View {
NavigationStack {
Form {
Section {
SecureField(provider.placeholder, text: $key)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.accessibilityLabel("\(provider.displayName) API key")
.accessibilityValue(key.isEmpty ? "Empty" : "Entered")
} header: {
Text(provider.displayName)
} footer: {
Text(
"The key is stored with device-only Keychain protection. Wayfinder will never display it again after saving."
)
}

Section {
Label(
"Adding this key does not change your routing preferences.",
systemImage: "checkmark.shield"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(
appModel.isCredentialConfigured(provider.id) ? "Replace Key" : "Add Key"
)
.navigationBarTitleDisplayMode(.inline)
.interactiveDismissDisabled(isSaving)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
key = ""
dismiss()
}
.disabled(isSaving)
}

ToolbarItem(placement: .confirmationAction) {
Button("Save") {
isSaving = true
Task {
let saved = await appModel.saveAPIKey(key, for: provider)
isSaving = false
if saved {
key = ""
dismiss()
}
}
}
.disabled(
isSaving
|| key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
}
.onDisappear {
key = ""
}
}
}
}
Loading
Loading