montana/macOS/MontanaPresence/SettingsView.swift

908 lines
36 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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