From 1d41129b6cab3f104ffc434d71c73c8fc0932961 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 21:19:34 +0000 Subject: [PATCH] feat(ios): add settings UI --- apps/ios/Sources/Settings/SettingsTab.swift | 147 ++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 apps/ios/Sources/Settings/SettingsTab.swift diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift new file mode 100644 index 000000000..4c0b486d6 --- /dev/null +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct SettingsTab: View { + @EnvironmentObject private var appModel: NodeAppModel + @AppStorage("node.displayName") private var displayName: String = "iOS Node" + @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString + @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false + @StateObject private var discovery = BridgeDiscoveryModel() + @State private var connectStatus: String? + @State private var isConnecting = false + @State private var didAutoConnect = false + + var body: some View { + NavigationStack { + Form { + Section("Node") { + TextField("Name", text: self.$displayName) + Text(self.instanceId) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Voice") { + Toggle("Voice Wake", isOn: self.$voiceWakeEnabled) + .onChange(of: self.voiceWakeEnabled) { _, newValue in + self.appModel.setVoiceWakeEnabled(newValue) + } + } + + Section("Bridge") { + LabeledContent("Discovery", value: self.discovery.statusText) + LabeledContent("Status", value: self.appModel.bridgeStatusText) + if let serverName = self.appModel.bridgeServerName { + Text("Server: \(serverName)") + .font(.footnote) + .foregroundStyle(.secondary) + } + Button("Disconnect") { self.appModel.disconnectBridge() } + if let connectStatus { + Text(connectStatus) + .font(.footnote) + .foregroundStyle(.secondary) + } + if self.discovery.bridges.isEmpty { + Text("No bridges found yet.") + .foregroundStyle(.secondary) + } else { + ForEach(self.discovery.bridges) { bridge in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(bridge.name) + Text(bridge.debugID) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button(self.isConnecting ? "…" : "Connect") { + Task { await self.connect(bridge) } + } + .disabled(self.isConnecting) + } + } + } + } + } + .navigationTitle("Settings") + .onAppear { self.discovery.start() } + .onDisappear { self.discovery.stop() } + .onChange(of: self.discovery.bridges) { _, newValue in + if self.didAutoConnect { return } + if self.appModel.bridgeServerName != nil { return } + + let existing = KeychainStore.loadString( + service: "com.steipete.clawdis.bridge", + account: self.keychainAccount()) + guard let existing, !existing.isEmpty else { return } + guard let first = newValue.first else { return } + + self.didAutoConnect = true + self.appModel.connectToBridge( + endpoint: first.endpoint, + token: existing, + nodeId: self.instanceId, + displayName: self.displayName, + platform: self.platformString(), + version: self.appVersion()) + self.connectStatus = "Auto-connected" + } + } + } + + private func keychainAccount() -> String { + "bridge-token.\(self.instanceId)" + } + + private func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "ios \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + private func appVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + } + + private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async { + self.isConnecting = true + defer { self.isConnecting = false } + + let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount()) + do { + let token: String + if let existing, !existing.isEmpty { + token = existing + } else { + let newToken = try await BridgeClient().pairAndHello( + endpoint: bridge.endpoint, + nodeId: self.instanceId, + displayName: self.displayName, + platform: self.platformString(), + version: self.appVersion(), + existingToken: nil) + guard !newToken.isEmpty else { + self.connectStatus = "Pairing failed: empty token" + return + } + _ = KeychainStore.saveString( + newToken, + service: "com.steipete.clawdis.bridge", + account: self.keychainAccount()) + token = newToken + } + + self.appModel.connectToBridge( + endpoint: bridge.endpoint, + token: token, + nodeId: self.instanceId, + displayName: self.displayName, + platform: self.platformString(), + version: self.appVersion()) + + self.connectStatus = "Connected" + } catch { + self.connectStatus = "Failed: \(error.localizedDescription)" + } + } +}