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%
11.0 KB · 308 lines swift
Raw Blame History
1//2//  OnboardingView.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Create-wallet flow: password → show mnemonic → verify 3 random words →12/// vault written. Import flow: phrase + password. The vault is only written13/// after backup verification succeeds (create) or the phrase validates (import).14struct OnboardingView: View {15    @EnvironmentObject var app: AppState1617    enum Step {18        case welcome19        case setPassword(importing: Bool)20        case showMnemonic21        case verifyMnemonic22        case importPhrase23    }2425    @State private var step: Step = .welcome26    @State private var password = ""27    @State private var passwordConfirm = ""28    @State private var mnemonic = ""29    @State private var importInput = ""30    @State private var verifyIndices: [Int] = []31    @State private var verifyInputs: [String] = ["", "", ""]32    @State private var errorMessage: String?3334    var body: some View {35        VStack(spacing: 0) {36            content37        }38        .padding(32)39        .frame(maxWidth: .infinity, maxHeight: .infinity)40    }4142    @ViewBuilder43    private var content: some View {44        switch step {45        case .welcome: welcome46        case .setPassword(let importing): setPassword(importing: importing)47        case .showMnemonic: showMnemonic48        case .verifyMnemonic: verifyMnemonic49        case .importPhrase: importPhrase50        }51    }5253    // MARK: - Welcome5455    private var welcome: some View {56        VStack(spacing: 20) {57            Image(systemName: "lock.shield")58                .font(.system(size: 56))59                .foregroundStyle(.blue)60            Text("OS Vault").font(.largeTitle.bold())61            Text("Self-custody wallet for stablecoins, ETH and Bitcoin —\n11 EVM chains + Bitcoin from one recovery phrase.\nKeys are encrypted locally with OS Vault's own vault format; they never leave this Mac.")62                .multilineTextAlignment(.center)63                .foregroundStyle(.secondary)64            VStack(spacing: 12) {65                Button("Create a new wallet") {66                    step = .setPassword(importing: false)67                }68                .buttonStyle(.borderedProminent)69                .controlSize(.large)70                Button("Import an existing wallet") {71                    step = .setPassword(importing: true)72                }73                .controlSize(.large)74            }75            .padding(.top, 8)76        }77    }7879    // MARK: - Password8081    private func setPassword(importing: Bool) -> some View {82        VStack(alignment: .leading, spacing: 16) {83            Text("Choose a vault password").font(.title2.bold())84            Text("This password encrypts your recovery phrase on this Mac (AES-256-GCM). It cannot be recovered — if you forget it, only your recovery phrase can restore the wallet.")85                .foregroundStyle(.secondary)86            SecureField("Password (min. 8 characters)", text: $password)87                .textFieldStyle(.roundedBorder)88            SecureField("Confirm password", text: $passwordConfirm)89                .textFieldStyle(.roundedBorder)90            if let errorMessage {91                Text(errorMessage).foregroundStyle(.red).font(.callout)92            }93            HStack {94                Button("Back") {95                    reset()96                }97                Spacer()98                Button("Continue") {99                    guard password.count >= 8 else {100                        errorMessage = "Password must be at least 8 characters."101                        return102                    }103                    guard password == passwordConfirm else {104                        errorMessage = "Passwords do not match."105                        return106                    }107                    errorMessage = nil108                    if importing {109                        step = .importPhrase110                    } else {111                        do {112                            mnemonic = try app.keyManager.generateMnemonic()113                            step = .showMnemonic114                        } catch {115                            errorMessage = error.localizedDescription116                        }117                    }118                }119                .buttonStyle(.borderedProminent)120            }121        }122        .frame(maxWidth: 420)123    }124125    // MARK: - Show mnemonic126127    private var mnemonicWords: [String] { mnemonic.split(separator: " ").map(String.init) }128129    private var showMnemonic: some View {130        VStack(alignment: .leading, spacing: 16) {131            Text("Your recovery phrase").font(.title2.bold())132            Text("Write these 12 words down on paper, in order. Anyone with these words controls your funds. OS Vault will never show them again without your password.")133                .foregroundStyle(.secondary)134            LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 10) {135                ForEach(Array(mnemonicWords.enumerated()), id: \.offset) { index, word in136                    HStack {137                        Text("\(index + 1).").foregroundStyle(.secondary).monospacedDigit()138                        Text(word).fontWeight(.medium)139                        Spacer()140                    }141                    .padding(8)142                    .background(.quaternary.opacity(0.5))143                    .clipShape(RoundedRectangle(cornerRadius: 6))144                }145            }146            HStack {147                Button("Back") { step = .setPassword(importing: false) }148                Spacer()149                Button("I wrote it down — verify") {150                    verifyIndices = Array(0..<12).shuffled().prefix(3).sorted()151                    verifyInputs = ["", "", ""]152                    errorMessage = nil153                    step = .verifyMnemonic154                }155                .buttonStyle(.borderedProminent)156            }157        }158        .frame(maxWidth: 480)159    }160161    // MARK: - Verify backup162163    private var verifyMnemonic: some View {164        VStack(alignment: .leading, spacing: 16) {165            Text("Verify your backup").font(.title2.bold())166            Text("Enter the requested words from your written backup.")167                .foregroundStyle(.secondary)168            ForEach(0..<3, id: \.self) { slot in169                HStack {170                    Text("Word #\(verifyIndices[slot] + 1)")171                        .frame(width: 90, alignment: .leading)172                    TextField("word", text: $verifyInputs[slot])173                        .textFieldStyle(.roundedBorder)174                        .autocorrectionDisabled()175                }176            }177            if let errorMessage {178                Text(errorMessage).foregroundStyle(.red).font(.callout)179            }180            HStack {181                Button("Show phrase again") { step = .showMnemonic }182                Spacer()183                Button("Confirm") { finishCreate() }184                    .buttonStyle(.borderedProminent)185            }186        }187        .frame(maxWidth: 420)188    }189190    private func finishCreate() {191        let words = mnemonicWords192        for slot in 0..<3 {193            let expected = words[verifyIndices[slot]]194            let given = verifyInputs[slot].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()195            guard given == expected else {196                errorMessage = "Word #\(verifyIndices[slot] + 1) does not match. Check your backup."197                return198            }199        }200        persist(mnemonic: mnemonic)201    }202203    // MARK: - Import204205    private var importPhrase: some View {206        VStack(alignment: .leading, spacing: 16) {207            Text("Import wallet").font(.title2.bold())208            Text("Enter your 12- or 24-word recovery phrase, separated by spaces.")209                .foregroundStyle(.secondary)210            TextEditor(text: $importInput)211                .font(.body.monospaced())212                .frame(height: 90)213                .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))214                .autocorrectionDisabled()215            if let errorMessage {216                Text(errorMessage).foregroundStyle(.red).font(.callout)217            }218            HStack {219                Button("Back") { step = .setPassword(importing: true) }220                Spacer()221                Button("Import") {222                    guard KeyManager.validate(mnemonic: importInput) else {223                        errorMessage = WalletError.invalidMnemonic.errorDescription224                        return225                    }226                    persist(mnemonic: importInput)227                }228                .buttonStyle(.borderedProminent)229            }230        }231        .frame(maxWidth: 480)232    }233234    // MARK: - Common235236    private func persist(mnemonic: String) {237        do {238            let wallet = try app.keyManager.saveWallet(mnemonic: mnemonic, password: password)239            reset()240            app.didUnlock(wallet: wallet)241        } catch {242            errorMessage = error.localizedDescription243        }244    }245246    private func reset() {247        password = ""248        passwordConfirm = ""249        mnemonic = ""250        importInput = ""251        verifyInputs = ["", "", ""]252        errorMessage = nil253        step = .welcome254    }255}256257/// Lock screen for an existing vault.258struct UnlockView: View {259    @EnvironmentObject var app: AppState260    @State private var password = ""261    @State private var errorMessage: String?262    @State private var unlocking = false263264    var body: some View {265        VStack(spacing: 20) {266            Image(systemName: "lock.fill")267                .font(.system(size: 44))268                .foregroundStyle(.blue)269            Text("OS Vault is locked").font(.title2.bold())270            SecureField("Vault password", text: $password)271                .textFieldStyle(.roundedBorder)272                .frame(maxWidth: 280)273                .onSubmit(unlock)274            if let errorMessage {275                Text(errorMessage).foregroundStyle(.red).font(.callout)276            }277            Button(unlocking ? "Unlocking…" : "Unlock") { unlock() }278                .buttonStyle(.borderedProminent)279                .controlSize(.large)280                .disabled(unlocking || password.isEmpty)281        }282        .padding(32)283        .frame(maxWidth: .infinity, maxHeight: .infinity)284    }285286    private func unlock() {287        guard !password.isEmpty else { return }288        unlocking = true289        errorMessage = nil290        let manager = app.keyManager291        let candidate = password292        Task.detached {293            // PBKDF2 at 600k rounds is deliberately slow; keep it off the main thread.294            let result = Result { try manager.unlock(password: candidate) }295            await MainActor.run {296                unlocking = false297                switch result {298                case .success(let wallet):299                    password = ""300                    app.didUnlock(wallet: wallet)301                case .failure(let error):302                    errorMessage = error.localizedDescription303                }304            }305        }306    }307}308