// // OnboardingView.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Create-wallet flow: password → show mnemonic → verify 3 random words → /// vault written. Import flow: phrase + password. The vault is only written /// after backup verification succeeds (create) or the phrase validates (import). struct OnboardingView: View { @EnvironmentObject var app: AppState enum Step { case welcome case setPassword(importing: Bool) case showMnemonic case verifyMnemonic case importPhrase } @State private var step: Step = .welcome @State private var password = "" @State private var passwordConfirm = "" @State private var mnemonic = "" @State private var importInput = "" @State private var verifyIndices: [Int] = [] @State private var verifyInputs: [String] = ["", "", ""] @State private var errorMessage: String? var body: some View { VStack(spacing: 0) { content } .padding(32) .frame(maxWidth: .infinity, maxHeight: .infinity) } @ViewBuilder private var content: some View { switch step { case .welcome: welcome case .setPassword(let importing): setPassword(importing: importing) case .showMnemonic: showMnemonic case .verifyMnemonic: verifyMnemonic case .importPhrase: importPhrase } } // MARK: - Welcome private var welcome: some View { VStack(spacing: 20) { Image(systemName: "lock.shield") .font(.system(size: 56)) .foregroundStyle(.blue) Text("OS Vault").font(.largeTitle.bold()) 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.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) VStack(spacing: 12) { Button("Create a new wallet") { step = .setPassword(importing: false) } .buttonStyle(.borderedProminent) .controlSize(.large) Button("Import an existing wallet") { step = .setPassword(importing: true) } .controlSize(.large) } .padding(.top, 8) } } // MARK: - Password private func setPassword(importing: Bool) -> some View { VStack(alignment: .leading, spacing: 16) { Text("Choose a vault password").font(.title2.bold()) 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.") .foregroundStyle(.secondary) SecureField("Password (min. 8 characters)", text: $password) .textFieldStyle(.roundedBorder) SecureField("Confirm password", text: $passwordConfirm) .textFieldStyle(.roundedBorder) if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Back") { reset() } Spacer() Button("Continue") { guard password.count >= 8 else { errorMessage = "Password must be at least 8 characters." return } guard password == passwordConfirm else { errorMessage = "Passwords do not match." return } errorMessage = nil if importing { step = .importPhrase } else { do { mnemonic = try app.keyManager.generateMnemonic() step = .showMnemonic } catch { errorMessage = error.localizedDescription } } } .buttonStyle(.borderedProminent) } } .frame(maxWidth: 420) } // MARK: - Show mnemonic private var mnemonicWords: [String] { mnemonic.split(separator: " ").map(String.init) } private var showMnemonic: some View { VStack(alignment: .leading, spacing: 16) { Text("Your recovery phrase").font(.title2.bold()) 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.") .foregroundStyle(.secondary) LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 10) { ForEach(Array(mnemonicWords.enumerated()), id: \.offset) { index, word in HStack { Text("\(index + 1).").foregroundStyle(.secondary).monospacedDigit() Text(word).fontWeight(.medium) Spacer() } .padding(8) .background(.quaternary.opacity(0.5)) .clipShape(RoundedRectangle(cornerRadius: 6)) } } HStack { Button("Back") { step = .setPassword(importing: false) } Spacer() Button("I wrote it down — verify") { verifyIndices = Array(0..<12).shuffled().prefix(3).sorted() verifyInputs = ["", "", ""] errorMessage = nil step = .verifyMnemonic } .buttonStyle(.borderedProminent) } } .frame(maxWidth: 480) } // MARK: - Verify backup private var verifyMnemonic: some View { VStack(alignment: .leading, spacing: 16) { Text("Verify your backup").font(.title2.bold()) Text("Enter the requested words from your written backup.") .foregroundStyle(.secondary) ForEach(0..<3, id: \.self) { slot in HStack { Text("Word #\(verifyIndices[slot] + 1)") .frame(width: 90, alignment: .leading) TextField("word", text: $verifyInputs[slot]) .textFieldStyle(.roundedBorder) .autocorrectionDisabled() } } if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Show phrase again") { step = .showMnemonic } Spacer() Button("Confirm") { finishCreate() } .buttonStyle(.borderedProminent) } } .frame(maxWidth: 420) } private func finishCreate() { let words = mnemonicWords for slot in 0..<3 { let expected = words[verifyIndices[slot]] let given = verifyInputs[slot].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard given == expected else { errorMessage = "Word #\(verifyIndices[slot] + 1) does not match. Check your backup." return } } persist(mnemonic: mnemonic) } // MARK: - Import private var importPhrase: some View { VStack(alignment: .leading, spacing: 16) { Text("Import wallet").font(.title2.bold()) Text("Enter your 12- or 24-word recovery phrase, separated by spaces.") .foregroundStyle(.secondary) TextEditor(text: $importInput) .font(.body.monospaced()) .frame(height: 90) .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) .autocorrectionDisabled() if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Back") { step = .setPassword(importing: true) } Spacer() Button("Import") { guard KeyManager.validate(mnemonic: importInput) else { errorMessage = WalletError.invalidMnemonic.errorDescription return } persist(mnemonic: importInput) } .buttonStyle(.borderedProminent) } } .frame(maxWidth: 480) } // MARK: - Common private func persist(mnemonic: String) { do { let wallet = try app.keyManager.saveWallet(mnemonic: mnemonic, password: password) reset() app.didUnlock(wallet: wallet) } catch { errorMessage = error.localizedDescription } } private func reset() { password = "" passwordConfirm = "" mnemonic = "" importInput = "" verifyInputs = ["", "", ""] errorMessage = nil step = .welcome } } /// Lock screen for an existing vault. struct UnlockView: View { @EnvironmentObject var app: AppState @State private var password = "" @State private var errorMessage: String? @State private var unlocking = false var body: some View { VStack(spacing: 20) { Image(systemName: "lock.fill") .font(.system(size: 44)) .foregroundStyle(.blue) Text("OS Vault is locked").font(.title2.bold()) SecureField("Vault password", text: $password) .textFieldStyle(.roundedBorder) .frame(maxWidth: 280) .onSubmit(unlock) if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } Button(unlocking ? "Unlocking…" : "Unlock") { unlock() } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(unlocking || password.isEmpty) } .padding(32) .frame(maxWidth: .infinity, maxHeight: .infinity) } private func unlock() { guard !password.isEmpty else { return } unlocking = true errorMessage = nil let manager = app.keyManager let candidate = password Task.detached { // PBKDF2 at 600k rounds is deliberately slow; keep it off the main thread. let result = Result { try manager.unlock(password: candidate) } await MainActor.run { unlocking = false switch result { case .success(let wallet): password = "" app.didUnlock(wallet: wallet) case .failure(let error): errorMessage = error.localizedDescription } } } } }