SPB Git

spb/os-vault Public

Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.

Swift 96% Shell 3.4% Makefile 0.6%
7.4 KB · 202 lines swift
Raw Blame History
1//2//  SettingsView.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011struct SettingsView: View {12    @EnvironmentObject var app: AppState13    @Environment(\.dismiss) private var dismiss1415    @State private var rpcOverride = ""16    @State private var exportPassword = ""17    @State private var exportedMnemonic: String?18    @State private var exportError: String?19    @State private var deleteConfirmation = ""20    @State private var deleteError: String?2122    var body: some View {23        VStack(alignment: .leading, spacing: 0) {24            HStack {25                Text("Settings").font(.title2.bold())26                Spacer()27                Button("Done") { dismiss() }28                    .keyboardShortcut(.defaultAction)29            }30            .padding(.bottom, 12)3132            ScrollView {33                VStack(alignment: .leading, spacing: 20) {34                    networkSection35                    Divider()36                    pricesSection37                    Divider()38                    rpcSection39                    Divider()40                    exportSection41                    Divider()42                    deleteSection43                }44                .padding(.vertical, 4)45            }46        }47        .padding(24)48        .frame(width: 480, height: 560)49        .onAppear {50            rpcOverride = UserDefaults.standard.string(forKey: app.network.rpcOverrideKey) ?? ""51        }52    }5354    // MARK: - Network5556    private var networkSection: some View {57        VStack(alignment: .leading, spacing: 8) {58            Text("Network").font(.headline)59            Picker("Active network", selection: $app.network) {60                ForEach(Network.allCases) { network in61                    Text(network.config.displayName + (network.config.isTestnet ? " (testnet)" : ""))62                        .tag(network)63                }64            }65            .pickerStyle(.menu)66            if !app.network.config.isTestnet {67                Label("Mainnet moves real funds. Double-check every send.",68                      systemImage: "exclamationmark.triangle.fill")69                    .font(.callout)70                    .foregroundStyle(.orange)71            }72        }73        .onChange(of: app.network) { _, newNetwork in74            rpcOverride = UserDefaults.standard.string(forKey: newNetwork.rpcOverrideKey) ?? ""75        }76    }7778    // MARK: - Prices7980    private var pricesSection: some View {81        VStack(alignment: .leading, spacing: 8) {82            Text("Fiat values").font(.headline)83            Toggle("Show fiat values (fetches prices from CoinGecko, keyless)", isOn: $app.pricesEnabled)84            if app.pricesEnabled {85                Picker("Currency", selection: $app.fiatCurrency) {86                    ForEach(PriceService.fiatOptions, id: \.self) { Text($0).tag($0) }87                }88                .pickerStyle(.segmented)89                .frame(maxWidth: 260)90            }91            Text("Balances are always exact on-chain amounts; fiat values are display estimates. Turning this off removes the only non-blockchain network call.")92                .font(.caption)93                .foregroundStyle(.secondary)94        }95    }9697    // MARK: - RPC9899    private var rpcSection: some View {100        VStack(alignment: .leading, spacing: 8) {101            Text("Custom RPC for \(app.network.config.displayName)").font(.headline)102            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.")103                .font(.caption)104                .foregroundStyle(.secondary)105            HStack {106                TextField("https://…", text: $rpcOverride)107                    .textFieldStyle(.roundedBorder)108                    .autocorrectionDisabled()109                Button("Save") {110                    let trimmed = rpcOverride.trimmingCharacters(in: .whitespacesAndNewlines)111                    if trimmed.isEmpty {112                        UserDefaults.standard.removeObject(forKey: app.network.rpcOverrideKey)113                    } else {114                        UserDefaults.standard.set(trimmed, forKey: app.network.rpcOverrideKey)115                    }116                    Task { await app.refreshBalances() }117                }118            }119        }120    }121122    // MARK: - Export seed123124    private var exportSection: some View {125        VStack(alignment: .leading, spacing: 8) {126            Text("Recovery phrase").font(.headline)127            if let exportedMnemonic {128                Text(exportedMnemonic)129                    .font(.callout.monospaced())130                    .textSelection(.enabled)131                    .padding(10)132                    .frame(maxWidth: .infinity, alignment: .leading)133                    .background(Color.orange.opacity(0.12))134                    .clipShape(RoundedRectangle(cornerRadius: 8))135                Button("Hide") {136                    self.exportedMnemonic = nil137                    exportPassword = ""138                }139            } else {140                Text("Reveals the 12 words that control your funds. Make sure nobody can see your screen.")141                    .font(.caption)142                    .foregroundStyle(.secondary)143                HStack {144                    SecureField("Vault password", text: $exportPassword)145                        .textFieldStyle(.roundedBorder)146                    Button("Reveal") { export() }147                        .disabled(exportPassword.isEmpty)148                }149                if let exportError {150                    Text(exportError).foregroundStyle(.red).font(.callout)151                }152            }153        }154    }155156    private func export() {157        exportError = nil158        let manager = app.keyManager159        let candidate = exportPassword160        Task {161            let result = await Task.detached {162                Result { try manager.exportMnemonic(password: candidate) }163            }.value164            switch result {165            case .success(let mnemonic): exportedMnemonic = mnemonic166            case .failure(let error): exportError = error.localizedDescription167            }168        }169    }170171    // MARK: - Delete172173    private var deleteSection: some View {174        VStack(alignment: .leading, spacing: 8) {175            Text("Danger zone").font(.headline).foregroundStyle(.red)176            Text("Deletes the encrypted vault from this Mac. Without your written recovery phrase, the funds are unrecoverable. Type DELETE to confirm.")177                .font(.caption)178                .foregroundStyle(.secondary)179            HStack {180                TextField("Type DELETE", text: $deleteConfirmation)181                    .textFieldStyle(.roundedBorder)182                Button("Delete wallet", role: .destructive) {183                    guard deleteConfirmation == "DELETE" else {184                        deleteError = "Type DELETE (all caps) to confirm."185                        return186                    }187                    do {188                        try app.keyManager.deleteVault()189                        dismiss()190                        app.walletDeleted()191                    } catch {192                        deleteError = error.localizedDescription193                    }194                }195            }196            if let deleteError {197                Text(deleteError).foregroundStyle(.red).font(.callout)198            }199        }200    }201}202