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%
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