908 lines
36 KiB
Swift
908 lines
36 KiB
Swift
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<Void, Never>?
|
||
@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<Void, Never>?
|
||
@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..<currentPinLength, id: \.self) { i in
|
||
Circle()
|
||
.fill(i < pinInput.count ? gold : Color.white.opacity(0.2))
|
||
.frame(width: 12, height: 12)
|
||
}
|
||
}
|
||
.offset(x: pinShake ? -10 : 0)
|
||
.padding(.bottom, 28)
|
||
}
|
||
|
||
// Number pad (disabled during lockout)
|
||
VStack(spacing: padSpacing) {
|
||
ForEach(0..<3) { row in
|
||
HStack(spacing: padSpacing) {
|
||
ForEach(1...3, id: \.self) { col in
|
||
let num = row * 3 + col
|
||
pinButton(String(num))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Bottom row: Cancel / 0 / Delete
|
||
HStack(spacing: padSpacing) {
|
||
Button {
|
||
dismissPin()
|
||
} label: {
|
||
Text("Отмена")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.gray)
|
||
.frame(width: padSize, height: padSize)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
pinButton("0")
|
||
|
||
Button {
|
||
if !pinInput.isEmpty {
|
||
pinInput.removeLast()
|
||
}
|
||
} label: {
|
||
Image(systemName: "delete.backward")
|
||
.font(.system(size: 18))
|
||
.symbolRenderingMode(.monochrome)
|
||
.foregroundColor(crypto.isLockedOut ? .gray.opacity(0.3) : .white)
|
||
.frame(width: padSize, height: padSize)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(crypto.isLockedOut)
|
||
}
|
||
}
|
||
|
||
Spacer(minLength: 20)
|
||
}
|
||
}
|
||
.onAppear { startLockoutTimerIfNeeded() }
|
||
}
|
||
|
||
private func pinButton(_ digit: String) -> 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
|
||
}
|
||
}
|
||
}
|