montana/macOS/MontanaPresence/SettingsView.swift

908 lines
36 KiB
Swift
Raw Permalink Normal View History

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 // MT1MT2 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 MT1MT2 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 {
// MT1MT2 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
}
}
}