import SwiftUI import ServiceManagement // MARK: - PIN Screen Mode enum PinMode { case none case create // "Создайте PIN" case confirm // "Повторите PIN" case verify // "Введите PIN" (for seed/change/delete) case changeNew // "Новый PIN" (after verifying old) case changeConfirm // "Повторите новый PIN" } enum PinPurpose { case seedPhrase case changePin case deletePin case createNew case createIdentity // PIN-first: create PIN → generate keys → encrypt → show mnemonic case migratePin // MT1→MT2 migration (6-digit → 8-digit) } struct SettingsView: View { @EnvironmentObject var engine: PresenceEngine @EnvironmentObject var updater: UpdateManager @EnvironmentObject var vpn: VPNManager @EnvironmentObject var crypto: CryptoManager @EnvironmentObject var lang: LanguageManager @State private var addressInput: String = "" @State private var launchAtLogin = false @State private var saved = false @State private var showSidebar = false @State private var isGenerating = false @State private var showDeleteConfirm = false @State private var mnemonicWords: [String] = [] @State private var showMnemonic = false @State private var showSeedViewer = false @State private var seedViewerWords: [String] = [] @State private var seedCopied = false @State private var seedAutoHideTask: Task? @State private var seedSecondsLeft: Int = 0 @State private var showRecovery = false @State private var recoveryInput: String = "" @State private var recoveryError = false // PIN state @State private var pinMode: PinMode = .none @State private var pinPurpose: PinPurpose = .seedPhrase @State private var pinInput: String = "" @State private var pinFirst: String = "" @State private var pinError: String = "" @State private var pinShake: Bool = false @State private var lockoutTimer: Task? @State private var lockoutDisplay: String = "" // showDeletePinConfirm removed — PIN is mandatory, cannot be deleted private let gold = Color(red: 0.85, green: 0.68, blue: 0.25) private let goldLight = Color(red: 0.95, green: 0.82, blue: 0.45) private let pinLength = 8 // Migration state @State private var migrationOldPin: String = "" /// Dynamic PIN length: 6 for old PIN during migration, 8 for everything else private var currentPinLength: Int { if pinPurpose == .migratePin && pinMode == .verify { return 6 // Old 6-digit PIN during migration } return pinLength // 8 for all new PINs } var body: some View { ZStack { // Main settings content ZStack(alignment: .leading) { VStack(spacing: 0) { // ── BURGER MENU BUTTON ── HStack { Button(action: { withAnimation(.easeInOut(duration: 0.3)) { showSidebar = true } }) { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) .foregroundColor(gold) .padding(8) } .buttonStyle(.plain) Spacer() } .padding(.horizontal, 12) .padding(.top, 8) Form { Section("ML-DSA-65 Ключи") { if crypto.hasIdentity { LabeledContent("Адрес") { HStack(spacing: 6) { Text(crypto.address) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .lineLimit(1) Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(crypto.address, forType: .string) saved = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { saved = false } } label: { Image(systemName: saved ? "checkmark" : "doc.on.doc") .foregroundColor(saved ? .green : gold) } .buttonStyle(.plain) } } LabeledContent("Криптография") { Text("ML-DSA-65 (FIPS 204)") .font(.caption) .foregroundColor(.secondary) } LabeledContent("Ключи") { HStack(spacing: 4) { Circle().fill(Color.green).frame(width: 8, height: 8) Text("Активны") .font(.caption) .foregroundColor(.green) } } Button(role: .destructive) { showDeleteConfirm = true } label: { Label("Удалить ключи", systemImage: "trash") } .alert("Удалить ключи?", isPresented: $showDeleteConfirm) { Button("Удалить", role: .destructive) { crypto.deleteIdentity() engine.address = nil } Button("Отмена", role: .cancel) {} } message: { Text("Приватный ключ будет удалён из Keychain. Это необратимо.") } } else if showMnemonic { // ── Show 24 words after generation ── VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text("Запишите 24 слова! Это единственный способ восстановить ключи.") .font(.caption) .foregroundColor(.orange) } seedWordsGrid(mnemonicWords) HStack(spacing: 8) { Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(mnemonicWords.joined(separator: " "), forType: .string) saved = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { saved = false } } label: { Label(saved ? "Скопировано" : "Копировать", systemImage: saved ? "checkmark" : "doc.on.doc") } .buttonStyle(.bordered) Spacer() Button("Готово") { showMnemonic = false mnemonicWords = [] } .buttonStyle(.borderedProminent) .tint(gold) } } } else if showRecovery { // ── Recovery from seed phrase ── VStack(alignment: .leading, spacing: 8) { Text("Введите 24 слова через пробел:") .font(.caption) .foregroundColor(.secondary) TextEditor(text: $recoveryInput) .font(.system(.caption, design: .monospaced)) .frame(height: 80) .scrollContentBackground(.hidden) .background(Color.black.opacity(0.2)) .cornerRadius(6) if recoveryError { Text("Неверная seed-фраза. Проверьте слова и попробуйте снова.") .font(.caption) .foregroundColor(.red) } HStack(spacing: 8) { Button("Отмена") { showRecovery = false recoveryInput = "" recoveryError = false } .buttonStyle(.bordered) Spacer() Button { let words = recoveryInput.lowercased() .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: " ").map(String.init) if crypto.recoverIdentity(from: words) { engine.address = crypto.address showRecovery = false recoveryInput = "" recoveryError = false } else { recoveryError = true } } label: { Label("Восстановить", systemImage: "arrow.counterclockwise") } .buttonStyle(.borderedProminent) .tint(gold) } } } else { Text("Ключи не созданы") .foregroundColor(.secondary) Button { openPinScreen(purpose: .createIdentity) } label: { Label("Создать ключи ML-DSA-65", systemImage: "key.fill") } .buttonStyle(.borderedProminent) .tint(gold) Button { showRecovery = true } label: { Label("Восстановить из seed-фразы", systemImage: "arrow.counterclockwise") } .buttonStyle(.bordered) } } // ── SECURITY SECTION ── if crypto.hasIdentity { Section("Безопасность") { // Seed phrase if showSeedViewer { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: "exclamationmark.shield.fill") .foregroundColor(.orange) VStack(alignment: .leading, spacing: 2) { Text("Никому не показывайте!") .font(.caption).bold() .foregroundColor(.orange) Text("Seed-фраза = полный доступ к кошельку") .font(.caption2) .foregroundColor(.secondary) } Spacer() Text("\(seedSecondsLeft)с") .font(.system(.caption, design: .monospaced)) .foregroundColor(seedSecondsLeft <= 10 ? .red : .secondary) .monospacedDigit() } .padding(8) .background(Color.orange.opacity(0.1)) .cornerRadius(8) seedWordsGrid(seedViewerWords) HStack(spacing: 8) { Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(seedViewerWords.joined(separator: " "), forType: .string) seedCopied = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { seedCopied = false } } label: { Label(seedCopied ? "Скопировано" : "Копировать", systemImage: seedCopied ? "checkmark" : "doc.on.doc") } .buttonStyle(.bordered) Spacer() Button("Скрыть") { hideSeedPhrase() } .buttonStyle(.borderedProminent) .tint(gold) } } } else { Button { openPinScreen(purpose: .seedPhrase) } label: { HStack { Label("Seed-фраза", systemImage: "eye.slash") Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } } .buttonStyle(.plain) } // Encryption status (PIN is always set — mandatory) LabeledContent("Шифрование") { HStack(spacing: 4) { Circle().fill(Color.green).frame(width: 8, height: 8) Text("Argon2id + AES-256") .font(.caption) .foregroundColor(.green) } } LabeledContent("PIN-код") { HStack(spacing: 4) { Circle().fill(Color.green).frame(width: 8, height: 8) Text("Установлен (8 цифр)") .font(.caption) .foregroundColor(.green) } } // PIN is mandatory — can only be changed, never deleted if crypto.hasPin { Button { openPinScreen(purpose: .changePin) } label: { Label("Сменить PIN", systemImage: "arrow.triangle.2.circlepath") } } } } Section("Запуск") { Toggle("Запускать при входе", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, newValue in toggleLoginItem(enabled: newValue) } } Section("Menu Bar") { Toggle("Символ \u{0248}", isOn: Binding( get: { engine.showSymbolInMenuBar }, set: { _ in engine.toggleMenuBarSymbol() } )) Toggle("Баланс", isOn: Binding( get: { engine.showBalanceInMenuBar }, set: { _ in engine.toggleMenuBarBalance() } )) } Section("TimeChain") { LabeledContent(lang.isRu ? "Подтверждённый баланс" : "Confirmed Balance") { Text("\(engine.serverBalance) \u{0248}") .font(.system(.body, design: .monospaced)) } LabeledContent(lang.isRu ? "Ожидает подтверждения" : "Pending Confirmation") { Text("+\(engine.pendingSeconds)") .font(.system(.body, design: .monospaced)) } LabeledContent(lang.isRu ? "Сеть" : "Network") { HStack { Circle() .fill(engine.isOnline ? Color.green : Color.red) .frame(width: 8, height: 8) Text(engine.isOnline ? (lang.isRu ? "Онлайн" : "Online") : (lang.isRu ? "Офлайн" : "Offline")) } } } Section("О приложении") { LabeledContent("Версия") { Text("Montana \(updater.currentVersion)") } LabeledContent("Протокол") { Text("Montana Protocol \u{0248}") } if updater.updateAvailable { LabeledContent("Обновление") { HStack { Text("v\(updater.latestVersion)") .foregroundColor(Color(red: 0, green: 0.83, blue: 1)) Button("Установить") { Task { await updater.downloadAndInstall() } } .buttonStyle(.borderedProminent) .tint(Color(red: 0, green: 0.83, blue: 1)) .controlSize(.small) .disabled(updater.isDownloading) } } } HStack { Spacer() Button("Проверить обновления") { Task { await updater.checkForUpdate() } } .controlSize(.small) } } } .formStyle(.grouped) .scrollContentBackground(.hidden) .onAppear { launchAtLogin = SMAppService.mainApp.status == .enabled if crypto.hasIdentity && engine.address != crypto.address { engine.address = crypto.address } // Check for MT1→MT2 migration needed if crypto.needsMT2Migration { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { openPinScreen(purpose: .migratePin) } } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(red: 0.04, green: 0.04, blue: 0.04)) // Sidebar overlay if showSidebar { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { showSidebar = false } } SharedSidebar(isVisible: $showSidebar) .transition(.move(edge: .leading)) } } // ── PIN LOCK SCREEN OVERLAY ── if pinMode != .none { pinLockScreen .transition(.opacity) .zIndex(100) } } } // MARK: - Seed Words Grid (reused) private func seedWordsGrid(_ words: [String]) -> some View { LazyVGrid(columns: [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()) ], spacing: 4) { ForEach(Array(words.enumerated()), id: \.offset) { idx, word in HStack(spacing: 2) { Text("\(idx + 1).") .font(.system(.caption2, design: .monospaced)) .foregroundColor(.secondary) .frame(width: 20, alignment: .trailing) Text(word) .font(.system(.caption, design: .monospaced)) .foregroundColor(gold) } .frame(maxWidth: .infinity, alignment: .leading) } } } // MARK: - PIN Lock Screen (Full-screen, phone-style) private let padSize: CGFloat = 56 private let padSpacing: CGFloat = 18 private var pinLockScreen: some View { ZStack { Color.black.ignoresSafeArea() VStack(spacing: 0) { Spacer(minLength: 20) // Lock icon Image(systemName: pinLockIcon) .font(.system(size: 28, weight: .thin)) .symbolRenderingMode(.monochrome) .foregroundColor(gold) .padding(.bottom, 12) // Title Text(pinTitle) .font(.system(size: 18, weight: .semibold)) .foregroundColor(.white) .padding(.bottom, 4) // Subtitle Text(pinSubtitle) .font(.system(size: 12)) .foregroundColor(.gray) .padding(.bottom, 16) // Lockout message if crypto.isLockedOut { VStack(spacing: 8) { Image(systemName: "clock.badge.exclamationmark") .font(.system(size: 24)) .foregroundColor(.red) Text("Слишком много попыток") .font(.system(size: 14, weight: .medium)) .foregroundColor(.red) Text(lockoutDisplay) .font(.system(size: 20, weight: .bold, design: .monospaced)) .foregroundColor(.white) Divider().background(Color.gray.opacity(0.3)).padding(.vertical, 4) Text("Забыли PIN?") .font(.system(size: 12)) .foregroundColor(.gray) Button { dismissPin() showRecovery = true } label: { Text("Восстановить из 24 слов") .font(.system(size: 12, weight: .medium)) .foregroundColor(gold) } .buttonStyle(.plain) } .padding(.bottom, 20) } else { // Error message if !pinError.isEmpty { Text(pinError) .font(.system(size: 12, weight: .medium)) .foregroundColor(.red) .padding(.bottom, 6) } // PIN dots HStack(spacing: 14) { ForEach(0.. some View { Button { pinDigitPressed(digit) } label: { Text(digit) .font(.system(size: 24, weight: .regular, design: .rounded)) .foregroundColor(crypto.isLockedOut ? .gray.opacity(0.3) : .white) .frame(width: padSize, height: padSize) .background(Circle().fill(Color.white.opacity(crypto.isLockedOut ? 0.02 : 0.07))) } .buttonStyle(.plain) .disabled(crypto.isLockedOut) } private var pinLockIcon: String { switch pinMode { case .create, .confirm, .changeNew, .changeConfirm: return "lock.open" case .verify: return "lock" case .none: return "lock" } } private var pinTitle: String { switch pinMode { case .create: return "Создайте PIN-код" case .confirm: return "Повторите PIN-код" case .verify: switch pinPurpose { case .seedPhrase: return "Введите PIN-код" case .changePin: return "Текущий PIN-код" case .deletePin: return "Введите PIN для удаления" case .createNew: return "" case .createIdentity: return "" case .migratePin: return "Текущий PIN (6 цифр)" } case .changeNew: return "Новый PIN-код" case .changeConfirm: return "Повторите новый PIN" case .none: return "" } } private var pinSubtitle: String { switch pinMode { case .create: return "8 цифр для защиты кошелька" case .confirm: return "Введите PIN ещё раз" case .verify: let left = max(3 - crypto.failedAttempts, 0) if left > 0 && left < 3 { return "Осталось попыток: \(left)" } return "" case .changeNew: if pinPurpose == .migratePin { return "Новый PIN из 8 цифр (Argon2id)" } return "Введите новый PIN из 8 цифр" case .changeConfirm: return "Подтвердите новый PIN" case .none: return "" } } // MARK: - PIN Logic private func openPinScreen(purpose: PinPurpose) { pinPurpose = purpose pinInput = "" pinFirst = "" pinError = "" switch purpose { case .seedPhrase: if crypto.hasPin { withAnimation { pinMode = .verify } } else { withAnimation { pinMode = .create } } case .changePin: withAnimation { pinMode = .verify } case .deletePin: withAnimation { pinMode = .verify } case .createNew: withAnimation { pinMode = .create } case .createIdentity: withAnimation { pinMode = .create } case .migratePin: withAnimation { pinMode = .verify } } startLockoutTimerIfNeeded() } private func pinDigitPressed(_ digit: String) { guard pinInput.count < currentPinLength, !crypto.isLockedOut else { return } pinInput += digit pinError = "" if pinInput.count == currentPinLength { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { handlePinComplete() } } } private func handlePinComplete() { switch pinMode { case .create: pinFirst = pinInput pinInput = "" withAnimation { pinMode = .confirm } case .confirm: if pinInput == pinFirst { if pinPurpose == .createIdentity { // PIN-first flow: generate identity → encrypt with PIN → show mnemonic let pin = pinInput pinFirst = "" if let words = crypto.generateIdentity() { if crypto.createPin(pin) { engine.address = crypto.address dismissPin() mnemonicWords = words showMnemonic = true } else { // Encryption failed — delete unprotected keys crypto.deleteIdentity() shakeAndReset("Ошибка шифрования") } } else { shakeAndReset("Ошибка генерации ключей") } } else if crypto.createPin(pinInput) { let createdPin = pinInput pinFirst = "" dismissPin() if pinPurpose == .seedPhrase { revealSeedPhrase(pin: createdPin) } } else { shakeAndReset("Ошибка сохранения") } } else { pinFirst = "" shakeAndReset("PIN-коды не совпадают") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { withAnimation { pinMode = .create } } } case .verify: if crypto.verifyPin(pinInput) { switch pinPurpose { case .seedPhrase: let verifiedPin = pinInput dismissPin() revealSeedPhrase(pin: verifiedPin) case .changePin: migrationOldPin = pinInput // Save old PIN for changePin pinInput = "" pinFirst = "" withAnimation { pinMode = .changeNew } case .deletePin: crypto.deletePin() dismissPin() case .createNew: dismissPin() case .createIdentity: dismissPin() case .migratePin: migrationOldPin = pinInput pinInput = "" pinFirst = "" withAnimation { pinMode = .changeNew } } } else { if crypto.isLockedOut { pinInput = "" pinError = "" startLockoutTimerIfNeeded() } else { shakeAndReset("Неверный PIN-код") } } case .changeNew: pinFirst = pinInput pinInput = "" withAnimation { pinMode = .changeConfirm } case .changeConfirm: if pinInput == pinFirst { let success: Bool if pinPurpose == .migratePin { // MT1→MT2 migration success = crypto.migrateMT1toMT2(oldPin: migrationOldPin, newPin: pinInput) migrationOldPin = "" } else { // Normal PIN change success = crypto.changePin(old: migrationOldPin, new: pinInput) migrationOldPin = "" } if success { pinFirst = "" dismissPin() } else { shakeAndReset("Ошибка сохранения") } } else { pinFirst = "" shakeAndReset("PIN-коды не совпадают") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { withAnimation { pinMode = .changeNew } } } case .none: break } } private func shakeAndReset(_ error: String) { pinError = error withAnimation(.default.speed(4).repeatCount(3, autoreverses: true)) { pinShake = true } DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { pinShake = false pinInput = "" } } private func dismissPin() { lockoutTimer?.cancel() lockoutTimer = nil withAnimation(.easeOut(duration: 0.2)) { pinMode = .none } pinInput = "" pinFirst = "" pinError = "" } // MARK: - Lockout Timer private func startLockoutTimerIfNeeded() { guard crypto.isLockedOut else { lockoutDisplay = "" return } lockoutTimer?.cancel() lockoutTimer = Task { @MainActor in while !Task.isCancelled { let secs = crypto.lockoutSecondsLeft if secs <= 0 { lockoutDisplay = "" break } let m = secs / 60 let s = secs % 60 if m >= 60 { let h = m / 60 lockoutDisplay = String(format: "%d:%02d:%02d", h, m % 60, s) } else { lockoutDisplay = String(format: "%d:%02d", m, s) } try? await Task.sleep(nanoseconds: 500_000_000) } } } // MARK: - Seed Phrase Flow private func revealSeedPhrase(pin: String) { guard let words = crypto.loadMnemonic(pin: pin) else { return } seedViewerWords = words showSeedViewer = true seedSecondsLeft = 60 seedAutoHideTask?.cancel() seedAutoHideTask = Task { @MainActor in for i in stride(from: 59, through: 0, by: -1) { try? await Task.sleep(nanoseconds: 1_000_000_000) if Task.isCancelled { return } seedSecondsLeft = i } hideSeedPhrase() } } private func hideSeedPhrase() { seedAutoHideTask?.cancel() seedAutoHideTask = nil showSeedViewer = false seedViewerWords = [] seedSecondsLeft = 0 seedCopied = false } // MARK: - Helpers private func isValidAddress(_ addr: String) -> Bool { addr.count == 42 && addr.hasPrefix("mt") } private func toggleLoginItem(enabled: Bool) { do { if enabled { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } } catch { launchAtLogin = SMAppService.mainApp.status == .enabled } } }