// // SettingsView.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI struct SettingsView: View { @EnvironmentObject var app: AppState @Environment(\.dismiss) private var dismiss @State private var rpcOverride = "" @State private var exportPassword = "" @State private var exportedMnemonic: String? @State private var exportError: String? @State private var deleteConfirmation = "" @State private var deleteError: String? var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { Text("Settings").font(.title2.bold()) Spacer() Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) } .padding(.bottom, 12) ScrollView { VStack(alignment: .leading, spacing: 20) { networkSection Divider() pricesSection Divider() rpcSection Divider() exportSection Divider() deleteSection } .padding(.vertical, 4) } } .padding(24) .frame(width: 480, height: 560) .onAppear { rpcOverride = UserDefaults.standard.string(forKey: app.network.rpcOverrideKey) ?? "" } } // MARK: - Network private var networkSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Network").font(.headline) Picker("Active network", selection: $app.network) { ForEach(Network.allCases) { network in Text(network.config.displayName + (network.config.isTestnet ? " (testnet)" : "")) .tag(network) } } .pickerStyle(.menu) if !app.network.config.isTestnet { Label("Mainnet moves real funds. Double-check every send.", systemImage: "exclamationmark.triangle.fill") .font(.callout) .foregroundStyle(.orange) } } .onChange(of: app.network) { _, newNetwork in rpcOverride = UserDefaults.standard.string(forKey: newNetwork.rpcOverrideKey) ?? "" } } // MARK: - Prices private var pricesSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Fiat values").font(.headline) Toggle("Show fiat values (fetches prices from CoinGecko, keyless)", isOn: $app.pricesEnabled) if app.pricesEnabled { Picker("Currency", selection: $app.fiatCurrency) { ForEach(PriceService.fiatOptions, id: \.self) { Text($0).tag($0) } } .pickerStyle(.segmented) .frame(maxWidth: 260) } Text("Balances are always exact on-chain amounts; fiat values are display estimates. Turning this off removes the only non-blockchain network call.") .font(.caption) .foregroundStyle(.secondary) } } // MARK: - RPC private var rpcSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Custom RPC for \(app.network.config.displayName)").font(.headline) Text("Leave empty to use the built-in keyless endpoints (\(app.network.config.rpcs.map(\.host!).joined(separator: ", "))) with automatic failover. A dedicated Alchemy/QuickNode URL is more reliable; no key is ever required to run OS Vault.") .font(.caption) .foregroundStyle(.secondary) HStack { TextField("https://…", text: $rpcOverride) .textFieldStyle(.roundedBorder) .autocorrectionDisabled() Button("Save") { let trimmed = rpcOverride.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { UserDefaults.standard.removeObject(forKey: app.network.rpcOverrideKey) } else { UserDefaults.standard.set(trimmed, forKey: app.network.rpcOverrideKey) } Task { await app.refreshBalances() } } } } } // MARK: - Export seed private var exportSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Recovery phrase").font(.headline) if let exportedMnemonic { Text(exportedMnemonic) .font(.callout.monospaced()) .textSelection(.enabled) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 8)) Button("Hide") { self.exportedMnemonic = nil exportPassword = "" } } else { Text("Reveals the 12 words that control your funds. Make sure nobody can see your screen.") .font(.caption) .foregroundStyle(.secondary) HStack { SecureField("Vault password", text: $exportPassword) .textFieldStyle(.roundedBorder) Button("Reveal") { export() } .disabled(exportPassword.isEmpty) } if let exportError { Text(exportError).foregroundStyle(.red).font(.callout) } } } } private func export() { exportError = nil let manager = app.keyManager let candidate = exportPassword Task { let result = await Task.detached { Result { try manager.exportMnemonic(password: candidate) } }.value switch result { case .success(let mnemonic): exportedMnemonic = mnemonic case .failure(let error): exportError = error.localizedDescription } } } // MARK: - Delete private var deleteSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Danger zone").font(.headline).foregroundStyle(.red) Text("Deletes the encrypted vault from this Mac. Without your written recovery phrase, the funds are unrecoverable. Type DELETE to confirm.") .font(.caption) .foregroundStyle(.secondary) HStack { TextField("Type DELETE", text: $deleteConfirmation) .textFieldStyle(.roundedBorder) Button("Delete wallet", role: .destructive) { guard deleteConfirmation == "DELETE" else { deleteError = "Type DELETE (all caps) to confirm." return } do { try app.keyManager.deleteVault() dismiss() app.walletDeleted() } catch { deleteError = error.localizedDescription } } } if let deleteError { Text(deleteError).foregroundStyle(.red).font(.callout) } } } }