701 lines
29 KiB
Swift
701 lines
29 KiB
Swift
|
|
import SwiftUI
|
|||
|
|
|
|||
|
|
struct MainWindowView: View {
|
|||
|
|
@EnvironmentObject var engine: PresenceEngine
|
|||
|
|
@EnvironmentObject var updater: UpdateManager
|
|||
|
|
@EnvironmentObject var vpn: VPNManager
|
|||
|
|
@EnvironmentObject var crypto: CryptoManager
|
|||
|
|
@EnvironmentObject var lang: LanguageManager
|
|||
|
|
@State private var selectedTab = 0
|
|||
|
|
@State private var pendingMnemonic: [String] = []
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
Group {
|
|||
|
|
if crypto.hasIdentity && pendingMnemonic.isEmpty {
|
|||
|
|
mainAppView
|
|||
|
|
} else {
|
|||
|
|
OnboardingView(pendingMnemonic: $pendingMnemonic)
|
|||
|
|
.environmentObject(crypto)
|
|||
|
|
.environmentObject(engine)
|
|||
|
|
.environmentObject(lang)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.frame(minWidth: 600, minHeight: 500)
|
|||
|
|
.background(Color(red: 0.04, green: 0.04, blue: 0.04))
|
|||
|
|
.preferredColorScheme(.dark)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@ViewBuilder
|
|||
|
|
private var mainAppView: some View {
|
|||
|
|
Group {
|
|||
|
|
switch selectedTab {
|
|||
|
|
case 0:
|
|||
|
|
JunonaView()
|
|||
|
|
.environmentObject(engine)
|
|||
|
|
case 1:
|
|||
|
|
WalletTabView()
|
|||
|
|
.environmentObject(engine)
|
|||
|
|
.environmentObject(updater)
|
|||
|
|
.environmentObject(vpn)
|
|||
|
|
case 7:
|
|||
|
|
HistoryView()
|
|||
|
|
.environmentObject(engine)
|
|||
|
|
case 8:
|
|||
|
|
TimeChainExplorerView()
|
|||
|
|
.environmentObject(engine)
|
|||
|
|
case 9:
|
|||
|
|
SettingsView()
|
|||
|
|
.environmentObject(engine)
|
|||
|
|
.environmentObject(lang)
|
|||
|
|
default:
|
|||
|
|
JunonaView()
|
|||
|
|
.environmentObject(engine)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.onReceive(NotificationCenter.default.publisher(for: .switchToSettingsTab)) { _ in
|
|||
|
|
selectedTab = 9
|
|||
|
|
}
|
|||
|
|
.onReceive(NotificationCenter.default.publisher(for: .switchToTab)) { notification in
|
|||
|
|
if let tab = notification.userInfo?["tab"] as? Int {
|
|||
|
|
selectedTab = tab
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ╔══════════════════════════════════════════════════════════════════╗
|
|||
|
|
// ║ ONBOARDING — Вход в Montana Protocol ║
|
|||
|
|
// ║ Ключи = вход. Без ключей — нет доступа к протоколу. ║
|
|||
|
|
// ╚══════════════════════════════════════════════════════════════════╝
|
|||
|
|
|
|||
|
|
struct OnboardingView: View {
|
|||
|
|
@Binding var pendingMnemonic: [String]
|
|||
|
|
@EnvironmentObject var crypto: CryptoManager
|
|||
|
|
@EnvironmentObject var engine: PresenceEngine
|
|||
|
|
@EnvironmentObject var lang: LanguageManager
|
|||
|
|
@State private var isGenerating = false
|
|||
|
|
@State private var showRecovery = false
|
|||
|
|
@State private var showKeychainInfo = false
|
|||
|
|
@State private var recoveryInput = ""
|
|||
|
|
@State private var recoveryError = false
|
|||
|
|
@State private var copied = false
|
|||
|
|
|
|||
|
|
// PIN creation state (mandatory before key generation)
|
|||
|
|
@State private var showPinCreate = false
|
|||
|
|
@State private var pinConfirmMode = false // false = create, true = confirm
|
|||
|
|
@State private var pinInput = ""
|
|||
|
|
@State private var pinFirst = ""
|
|||
|
|
@State private var pinError = ""
|
|||
|
|
@State private var pinShake = false
|
|||
|
|
private let pinLength = 8
|
|||
|
|
private let pinPadSize: CGFloat = 56
|
|||
|
|
private let pinPadSpacing: CGFloat = 18
|
|||
|
|
|
|||
|
|
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 bg = Color(red: 0.04, green: 0.04, blue: 0.04)
|
|||
|
|
|
|||
|
|
// ── Standard safe margins (магниты) ──
|
|||
|
|
// Ни один элемент не должен прижиматься к краю окна / тайтл-бару
|
|||
|
|
private let safeTop: CGFloat = 24
|
|||
|
|
private let safeBottom: CGFloat = 20
|
|||
|
|
private let safeSide: CGFloat = 20
|
|||
|
|
|
|||
|
|
private var appVersion: String {
|
|||
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "3.9"
|
|||
|
|
}
|
|||
|
|
private var appBuild: String {
|
|||
|
|
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "100"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
VStack(spacing: 0) {
|
|||
|
|
// ── Language pills (top right) ──
|
|||
|
|
HStack {
|
|||
|
|
Spacer()
|
|||
|
|
HStack(spacing: 6) {
|
|||
|
|
Button(action: { lang.setLanguage(.ru) }) {
|
|||
|
|
Text("\u{1F1F7}\u{1F1FA}")
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
.padding(.horizontal, 10)
|
|||
|
|
.padding(.vertical, 5)
|
|||
|
|
.background(
|
|||
|
|
Capsule()
|
|||
|
|
.fill(lang.isRu ? gold.opacity(0.2) : Color.white.opacity(0.04))
|
|||
|
|
)
|
|||
|
|
.overlay(
|
|||
|
|
Capsule()
|
|||
|
|
.stroke(lang.isRu ? gold.opacity(0.5) : Color.white.opacity(0.08), lineWidth: 1)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
|
|||
|
|
Button(action: { lang.setLanguage(.en) }) {
|
|||
|
|
Text("\u{1F1EC}\u{1F1E7}")
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
.padding(.horizontal, 10)
|
|||
|
|
.padding(.vertical, 5)
|
|||
|
|
.background(
|
|||
|
|
Capsule()
|
|||
|
|
.fill(lang.isEn ? gold.opacity(0.2) : Color.white.opacity(0.04))
|
|||
|
|
)
|
|||
|
|
.overlay(
|
|||
|
|
Capsule()
|
|||
|
|
.stroke(lang.isEn ? gold.opacity(0.5) : Color.white.opacity(0.08), lineWidth: 1)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
.padding(.trailing, safeSide)
|
|||
|
|
.padding(.top, safeTop)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Spacer()
|
|||
|
|
|
|||
|
|
if !pendingMnemonic.isEmpty {
|
|||
|
|
mnemonicView
|
|||
|
|
} else if showPinCreate {
|
|||
|
|
pinCreateView
|
|||
|
|
} else if showKeychainInfo {
|
|||
|
|
keychainInfoView
|
|||
|
|
} else if showRecovery {
|
|||
|
|
recoveryView
|
|||
|
|
} else {
|
|||
|
|
welcomeView
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Spacer()
|
|||
|
|
|
|||
|
|
// ── Footer with version ──
|
|||
|
|
HStack {
|
|||
|
|
Spacer()
|
|||
|
|
Text("Montana \u{0248} v\(appVersion)")
|
|||
|
|
.font(.system(size: 10, design: .monospaced))
|
|||
|
|
.foregroundColor(.white.opacity(0.15))
|
|||
|
|
Spacer()
|
|||
|
|
Text("build \(appBuild)")
|
|||
|
|
.font(.system(size: 10, design: .monospaced))
|
|||
|
|
.foregroundColor(.white.opacity(0.1))
|
|||
|
|
Spacer()
|
|||
|
|
}
|
|||
|
|
.padding(.bottom, safeBottom)
|
|||
|
|
}
|
|||
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|||
|
|
.background(bg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Welcome
|
|||
|
|
|
|||
|
|
private var welcomeView: some View {
|
|||
|
|
VStack(spacing: 32) {
|
|||
|
|
if let logoPath = Bundle.main.path(forResource: "JunonaLogo", ofType: "jpg"),
|
|||
|
|
let nsImage = NSImage(contentsOfFile: logoPath) {
|
|||
|
|
Image(nsImage: nsImage)
|
|||
|
|
.resizable()
|
|||
|
|
.aspectRatio(contentMode: .fill)
|
|||
|
|
.frame(width: 120, height: 120)
|
|||
|
|
.clipShape(Circle())
|
|||
|
|
.overlay(
|
|||
|
|
Circle()
|
|||
|
|
.stroke(
|
|||
|
|
LinearGradient(
|
|||
|
|
colors: [gold, goldLight],
|
|||
|
|
startPoint: .topLeading,
|
|||
|
|
endPoint: .bottomTrailing
|
|||
|
|
),
|
|||
|
|
lineWidth: 3
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
.shadow(color: gold.opacity(0.4), radius: 30)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
Text(lang.isRu ? "Протокол Монтана" : "Montana Protocol")
|
|||
|
|
.font(.system(size: 28, weight: .bold))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{041f}\u{043e}\u{0441}\u{0442}\u{043a}\u{0432}\u{0430}\u{043d}\u{0442}\u{043e}\u{0432}\u{0430}\u{044f} \u{043a}\u{0440}\u{0438}\u{043f}\u{0442}\u{043e}\u{0433}\u{0440}\u{0430}\u{0444}\u{0438}\u{044f} ML-DSA-65"
|
|||
|
|
: "Post-quantum cryptography ML-DSA-65")
|
|||
|
|
.font(.system(size: 13))
|
|||
|
|
.foregroundColor(.white.opacity(0.4))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
VStack(spacing: 12) {
|
|||
|
|
Button {
|
|||
|
|
showKeychainInfo = true
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 8) {
|
|||
|
|
Image(systemName: "key.fill")
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{0421}\u{043e}\u{0437}\u{0434}\u{0430}\u{0442}\u{044c} \u{043a}\u{043b}\u{044e}\u{0447}\u{0438}"
|
|||
|
|
: "Create Keys")
|
|||
|
|
.font(.system(size: 15, weight: .semibold))
|
|||
|
|
}
|
|||
|
|
.frame(width: 280, height: 44)
|
|||
|
|
.background(gold)
|
|||
|
|
.foregroundColor(.black)
|
|||
|
|
.cornerRadius(10)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
|
|||
|
|
Button {
|
|||
|
|
showRecovery = true
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 8) {
|
|||
|
|
Image(systemName: "arrow.counterclockwise")
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{0412}\u{043e}\u{0441}\u{0441}\u{0442}\u{0430}\u{043d}\u{043e}\u{0432}\u{0438}\u{0442}\u{044c} \u{0438}\u{0437} seed-\u{0444}\u{0440}\u{0430}\u{0437}\u{044b}"
|
|||
|
|
: "Recover from Seed Phrase")
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
}
|
|||
|
|
.frame(width: 280, height: 44)
|
|||
|
|
.background(gold.opacity(0.1))
|
|||
|
|
.foregroundColor(gold)
|
|||
|
|
.cornerRadius(10)
|
|||
|
|
.overlay(
|
|||
|
|
RoundedRectangle(cornerRadius: 10)
|
|||
|
|
.stroke(gold.opacity(0.3), lineWidth: 1)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Keychain Explanation
|
|||
|
|
|
|||
|
|
private var keychainInfoView: some View {
|
|||
|
|
VStack(spacing: 28) {
|
|||
|
|
Image(systemName: "lock.shield.fill")
|
|||
|
|
.font(.system(size: 50))
|
|||
|
|
.foregroundColor(gold)
|
|||
|
|
.shadow(color: gold.opacity(0.4), radius: 20)
|
|||
|
|
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "Безопасное хранение ключей"
|
|||
|
|
: "Secure Key Storage")
|
|||
|
|
.font(.system(size: 22, weight: .bold))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "macOS Keychain"
|
|||
|
|
: "macOS Keychain")
|
|||
|
|
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
|||
|
|
.foregroundColor(gold)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
VStack(alignment: .leading, spacing: 12) {
|
|||
|
|
keychainInfoRow(
|
|||
|
|
icon: "lock.fill",
|
|||
|
|
text: lang.isRu
|
|||
|
|
? "Ваши ключи ML-DSA-65 будут храниться в macOS Keychain \u{2014} зашифрованном хранилище вашего Mac. Ключи никогда не покидают устройство"
|
|||
|
|
: "Your ML-DSA-65 keys will be stored in macOS Keychain \u{2014} your Mac's encrypted vault. Keys never leave your device"
|
|||
|
|
)
|
|||
|
|
keychainInfoRow(
|
|||
|
|
icon: "shield.checkered",
|
|||
|
|
text: lang.isRu
|
|||
|
|
? "Это тот же механизм, который используют Safari, 1Password и другие приложения для хранения паролей"
|
|||
|
|
: "This is the same mechanism Safari, 1Password and other apps use to store passwords"
|
|||
|
|
)
|
|||
|
|
keychainInfoRow(
|
|||
|
|
icon: "hand.raised.fill",
|
|||
|
|
text: lang.isRu
|
|||
|
|
? "macOS запросит разрешение на доступ к хранилищу. Рекомендуем нажать \u{00ab}Разрешить всегда\u{00bb} для бесперебойной работы"
|
|||
|
|
: "macOS will ask for vault access permission. We recommend clicking \"Always Allow\" for seamless operation"
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
.frame(maxWidth: 440)
|
|||
|
|
|
|||
|
|
HStack(spacing: 16) {
|
|||
|
|
Button {
|
|||
|
|
showKeychainInfo = false
|
|||
|
|
} label: {
|
|||
|
|
Text(lang.isRu ? "Назад" : "Back")
|
|||
|
|
.frame(width: 140, height: 44)
|
|||
|
|
.background(Color.white.opacity(0.05))
|
|||
|
|
.foregroundColor(.white.opacity(0.7))
|
|||
|
|
.cornerRadius(10)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
|
|||
|
|
Button {
|
|||
|
|
showKeychainInfo = false
|
|||
|
|
showPinCreate = true
|
|||
|
|
pinConfirmMode = false
|
|||
|
|
pinInput = ""
|
|||
|
|
pinFirst = ""
|
|||
|
|
pinError = ""
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 8) {
|
|||
|
|
Image(systemName: "lock.fill")
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
Text(lang.isRu ? "Понятно, создать PIN" : "Got it, create PIN")
|
|||
|
|
.font(.system(size: 14, weight: .semibold))
|
|||
|
|
}
|
|||
|
|
.frame(width: 220, height: 44)
|
|||
|
|
.background(gold)
|
|||
|
|
.foregroundColor(.black)
|
|||
|
|
.cornerRadius(10)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func keychainInfoRow(icon: String, text: String) -> some View {
|
|||
|
|
HStack(alignment: .top, spacing: 12) {
|
|||
|
|
Image(systemName: icon)
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
.foregroundColor(gold)
|
|||
|
|
.frame(width: 20, alignment: .center)
|
|||
|
|
.padding(.top, 2)
|
|||
|
|
Text(text)
|
|||
|
|
.font(.system(size: 13))
|
|||
|
|
.foregroundColor(.white.opacity(0.6))
|
|||
|
|
.fixedSize(horizontal: false, vertical: true)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - PIN Creation (Onboarding)
|
|||
|
|
|
|||
|
|
private var pinCreateView: some View {
|
|||
|
|
ZStack {
|
|||
|
|
Color.black.ignoresSafeArea()
|
|||
|
|
|
|||
|
|
VStack(spacing: 0) {
|
|||
|
|
Spacer(minLength: 20)
|
|||
|
|
|
|||
|
|
Image(systemName: "lock.open")
|
|||
|
|
.font(.system(size: 28, weight: .thin))
|
|||
|
|
.symbolRenderingMode(.monochrome)
|
|||
|
|
.foregroundColor(gold)
|
|||
|
|
.padding(.bottom, 12)
|
|||
|
|
|
|||
|
|
Text(pinConfirmMode
|
|||
|
|
? (lang.isRu ? "Повторите PIN-код" : "Confirm PIN")
|
|||
|
|
: (lang.isRu ? "Создайте PIN-код" : "Create PIN"))
|
|||
|
|
.font(.system(size: 18, weight: .semibold))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
.padding(.bottom, 4)
|
|||
|
|
|
|||
|
|
Text(pinConfirmMode
|
|||
|
|
? (lang.isRu ? "Введите PIN ещё раз" : "Enter PIN again")
|
|||
|
|
: (lang.isRu ? "8 цифр для защиты кошелька" : "8 digits to protect your wallet"))
|
|||
|
|
.font(.system(size: 12))
|
|||
|
|
.foregroundColor(.gray)
|
|||
|
|
.padding(.bottom, 16)
|
|||
|
|
|
|||
|
|
if !pinError.isEmpty {
|
|||
|
|
Text(pinError)
|
|||
|
|
.font(.system(size: 12, weight: .medium))
|
|||
|
|
.foregroundColor(.red)
|
|||
|
|
.padding(.bottom, 6)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
HStack(spacing: 14) {
|
|||
|
|
ForEach(0..<pinLength, 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)
|
|||
|
|
|
|||
|
|
VStack(spacing: pinPadSpacing) {
|
|||
|
|
ForEach(0..<3) { row in
|
|||
|
|
HStack(spacing: pinPadSpacing) {
|
|||
|
|
ForEach(1...3, id: \.self) { col in
|
|||
|
|
let num = row * 3 + col
|
|||
|
|
onboardingPinButton(String(num))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
HStack(spacing: pinPadSpacing) {
|
|||
|
|
Button {
|
|||
|
|
showPinCreate = false
|
|||
|
|
showKeychainInfo = false
|
|||
|
|
pinInput = ""
|
|||
|
|
pinFirst = ""
|
|||
|
|
pinError = ""
|
|||
|
|
} label: {
|
|||
|
|
Text(lang.isRu ? "Отмена" : "Cancel")
|
|||
|
|
.font(.system(size: 14))
|
|||
|
|
.foregroundColor(.gray)
|
|||
|
|
.frame(width: pinPadSize, height: pinPadSize)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
|
|||
|
|
onboardingPinButton("0")
|
|||
|
|
|
|||
|
|
Button {
|
|||
|
|
if !pinInput.isEmpty { pinInput.removeLast() }
|
|||
|
|
} label: {
|
|||
|
|
Image(systemName: "delete.backward")
|
|||
|
|
.font(.system(size: 18))
|
|||
|
|
.symbolRenderingMode(.monochrome)
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
.frame(width: pinPadSize, height: pinPadSize)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Spacer(minLength: 20)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func onboardingPinButton(_ digit: String) -> some View {
|
|||
|
|
Button {
|
|||
|
|
onboardingPinDigitPressed(digit)
|
|||
|
|
} label: {
|
|||
|
|
Text(digit)
|
|||
|
|
.font(.system(size: 24, weight: .regular, design: .rounded))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
.frame(width: pinPadSize, height: pinPadSize)
|
|||
|
|
.background(Circle().fill(Color.white.opacity(0.07)))
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func onboardingPinDigitPressed(_ digit: String) {
|
|||
|
|
guard pinInput.count < pinLength else { return }
|
|||
|
|
pinInput += digit
|
|||
|
|
pinError = ""
|
|||
|
|
|
|||
|
|
if pinInput.count == pinLength {
|
|||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|||
|
|
handleOnboardingPinComplete()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func handleOnboardingPinComplete() {
|
|||
|
|
if !pinConfirmMode {
|
|||
|
|
// First entry — save and ask to confirm
|
|||
|
|
pinFirst = pinInput
|
|||
|
|
pinInput = ""
|
|||
|
|
pinConfirmMode = true
|
|||
|
|
} else {
|
|||
|
|
// Confirm entry — check match
|
|||
|
|
if pinInput == pinFirst {
|
|||
|
|
// PIN confirmed — generate identity + encrypt
|
|||
|
|
let pin = pinInput
|
|||
|
|
isGenerating = true
|
|||
|
|
Task {
|
|||
|
|
if let words = crypto.generateIdentity() {
|
|||
|
|
if crypto.createPin(pin) {
|
|||
|
|
engine.address = crypto.address
|
|||
|
|
showPinCreate = false
|
|||
|
|
pendingMnemonic = words
|
|||
|
|
} else {
|
|||
|
|
crypto.deleteIdentity()
|
|||
|
|
onboardingPinShakeAndReset(lang.isRu ? "Ошибка шифрования" : "Encryption error")
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
onboardingPinShakeAndReset(lang.isRu ? "Ошибка генерации ключей" : "Key generation error")
|
|||
|
|
}
|
|||
|
|
isGenerating = false
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Mismatch — restart
|
|||
|
|
pinFirst = ""
|
|||
|
|
onboardingPinShakeAndReset(lang.isRu ? "PIN-коды не совпадают" : "PINs don't match")
|
|||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
|||
|
|
pinConfirmMode = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func onboardingPinShakeAndReset(_ error: String) {
|
|||
|
|
pinError = error
|
|||
|
|
withAnimation(.default.speed(4).repeatCount(3, autoreverses: true)) {
|
|||
|
|
pinShake = true
|
|||
|
|
}
|
|||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
|||
|
|
pinShake = false
|
|||
|
|
pinInput = ""
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Mnemonic Display
|
|||
|
|
|
|||
|
|
private var mnemonicView: some View {
|
|||
|
|
VStack(spacing: 24) {
|
|||
|
|
HStack(spacing: 8) {
|
|||
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|||
|
|
.foregroundColor(.orange)
|
|||
|
|
.font(.system(size: 20))
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{0417}\u{0430}\u{043f}\u{0438}\u{0448}\u{0438}\u{0442}\u{0435} 24 \u{0441}\u{043b}\u{043e}\u{0432}\u{0430}!"
|
|||
|
|
: "Write Down 24 Words!")
|
|||
|
|
.font(.system(size: 18, weight: .bold))
|
|||
|
|
.foregroundColor(.orange)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{042d}\u{0442}\u{043e} \u{0435}\u{0434}\u{0438}\u{043d}\u{0441}\u{0442}\u{0432}\u{0435}\u{043d}\u{043d}\u{044b}\u{0439} \u{0441}\u{043f}\u{043e}\u{0441}\u{043e}\u{0431} \u{0432}\u{043e}\u{0441}\u{0441}\u{0442}\u{0430}\u{043d}\u{043e}\u{0432}\u{0438}\u{0442}\u{044c} \u{0434}\u{043e}\u{0441}\u{0442}\u{0443}\u{043f} \u{043a} \u{0432}\u{0430}\u{0448}\u{0435}\u{043c}\u{0443} \u{0430}\u{0434}\u{0440}\u{0435}\u{0441}\u{0443} Montana. \u{0425}\u{0440}\u{0430}\u{043d}\u{0438}\u{0442}\u{0435} \u{0432} \u{0431}\u{0435}\u{0437}\u{043e}\u{043f}\u{0430}\u{0441}\u{043d}\u{043e}\u{043c} \u{043c}\u{0435}\u{0441}\u{0442}\u{0435}."
|
|||
|
|
: "This is the only way to recover access to your Montana address. Keep it in a safe place.")
|
|||
|
|
.font(.system(size: 13))
|
|||
|
|
.foregroundColor(.white.opacity(0.5))
|
|||
|
|
.multilineTextAlignment(.center)
|
|||
|
|
.frame(maxWidth: 400)
|
|||
|
|
|
|||
|
|
LazyVGrid(columns: [
|
|||
|
|
GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())
|
|||
|
|
], spacing: 6) {
|
|||
|
|
ForEach(Array(pendingMnemonic.enumerated()), id: \.offset) { idx, word in
|
|||
|
|
HStack(spacing: 4) {
|
|||
|
|
Text("\(idx + 1).")
|
|||
|
|
.font(.system(size: 11, design: .monospaced))
|
|||
|
|
.foregroundColor(.white.opacity(0.3))
|
|||
|
|
.frame(width: 24, alignment: .trailing)
|
|||
|
|
Text(word)
|
|||
|
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
|||
|
|
.foregroundColor(gold)
|
|||
|
|
Spacer()
|
|||
|
|
}
|
|||
|
|
.padding(.vertical, 4)
|
|||
|
|
.padding(.horizontal, 8)
|
|||
|
|
.background(Color.white.opacity(0.03))
|
|||
|
|
.cornerRadius(4)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.frame(maxWidth: 420)
|
|||
|
|
|
|||
|
|
HStack(spacing: 16) {
|
|||
|
|
Button {
|
|||
|
|
NSPasteboard.general.clearContents()
|
|||
|
|
NSPasteboard.general.setString(pendingMnemonic.joined(separator: " "), forType: .string)
|
|||
|
|
copied = true
|
|||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 6) {
|
|||
|
|
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
|||
|
|
Text(copied
|
|||
|
|
? (lang.isRu ? "\u{0421}\u{043a}\u{043e}\u{043f}\u{0438}\u{0440}\u{043e}\u{0432}\u{0430}\u{043d}\u{043e}" : "Copied")
|
|||
|
|
: (lang.isRu ? "\u{041a}\u{043e}\u{043f}\u{0438}\u{0440}\u{043e}\u{0432}\u{0430}\u{0442}\u{044c}" : "Copy"))
|
|||
|
|
}
|
|||
|
|
.frame(width: 160, height: 40)
|
|||
|
|
.background(Color.white.opacity(0.05))
|
|||
|
|
.foregroundColor(.white.opacity(0.7))
|
|||
|
|
.cornerRadius(8)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
|
|||
|
|
Button {
|
|||
|
|
pendingMnemonic = []
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 6) {
|
|||
|
|
Image(systemName: "checkmark.circle.fill")
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{042f} \u{0441}\u{043e}\u{0445}\u{0440}\u{0430}\u{043d}\u{0438}\u{043b}"
|
|||
|
|
: "I Saved It")
|
|||
|
|
}
|
|||
|
|
.frame(width: 160, height: 40)
|
|||
|
|
.background(gold)
|
|||
|
|
.foregroundColor(.black)
|
|||
|
|
.cornerRadius(8)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Recovery
|
|||
|
|
|
|||
|
|
private var recoveryView: some View {
|
|||
|
|
VStack(spacing: 24) {
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
Image(systemName: "arrow.counterclockwise.circle.fill")
|
|||
|
|
.font(.system(size: 40))
|
|||
|
|
.foregroundColor(gold)
|
|||
|
|
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{0412}\u{043e}\u{0441}\u{0441}\u{0442}\u{0430}\u{043d}\u{043e}\u{0432}\u{043b}\u{0435}\u{043d}\u{0438}\u{0435}"
|
|||
|
|
: "Recovery")
|
|||
|
|
.font(.system(size: 20, weight: .bold))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{0412}\u{0432}\u{0435}\u{0434}\u{0438}\u{0442}\u{0435} 24 \u{0441}\u{043b}\u{043e}\u{0432}\u{0430} seed-\u{0444}\u{0440}\u{0430}\u{0437}\u{044b} \u{0447}\u{0435}\u{0440}\u{0435}\u{0437} \u{043f}\u{0440}\u{043e}\u{0431}\u{0435}\u{043b}:"
|
|||
|
|
: "Enter 24 seed phrase words separated by spaces:")
|
|||
|
|
.font(.system(size: 13))
|
|||
|
|
.foregroundColor(.white.opacity(0.5))
|
|||
|
|
|
|||
|
|
TextEditor(text: $recoveryInput)
|
|||
|
|
.font(.system(size: 13, design: .monospaced))
|
|||
|
|
.scrollContentBackground(.hidden)
|
|||
|
|
.padding(12)
|
|||
|
|
.frame(width: 400, height: 100)
|
|||
|
|
.background(Color.white.opacity(0.05))
|
|||
|
|
.cornerRadius(8)
|
|||
|
|
.overlay(
|
|||
|
|
RoundedRectangle(cornerRadius: 8)
|
|||
|
|
.stroke(recoveryError ? Color.red.opacity(0.5) : gold.opacity(0.2), lineWidth: 1)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if recoveryError {
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{041d}\u{0435}\u{0432}\u{0435}\u{0440}\u{043d}\u{0430}\u{044f} seed-\u{0444}\u{0440}\u{0430}\u{0437}\u{0430}. \u{041f}\u{0440}\u{043e}\u{0432}\u{0435}\u{0440}\u{044c}\u{0442}\u{0435} \u{0441}\u{043b}\u{043e}\u{0432}\u{0430} \u{0438} \u{043f}\u{043e}\u{043f}\u{0440}\u{043e}\u{0431}\u{0443}\u{0439}\u{0442}\u{0435} \u{0441}\u{043d}\u{043e}\u{0432}\u{0430}."
|
|||
|
|
: "Invalid seed phrase. Check words and try again.")
|
|||
|
|
.font(.system(size: 12))
|
|||
|
|
.foregroundColor(.red)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
HStack(spacing: 16) {
|
|||
|
|
Button {
|
|||
|
|
showRecovery = false
|
|||
|
|
recoveryInput = ""
|
|||
|
|
recoveryError = false
|
|||
|
|
} label: {
|
|||
|
|
Text(lang.isRu ? "\u{041d}\u{0430}\u{0437}\u{0430}\u{0434}" : "Back")
|
|||
|
|
.frame(width: 140, height: 40)
|
|||
|
|
.background(Color.white.opacity(0.05))
|
|||
|
|
.foregroundColor(.white.opacity(0.7))
|
|||
|
|
.cornerRadius(8)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
|
|||
|
|
Button {
|
|||
|
|
let words = recoveryInput.lowercased()
|
|||
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|||
|
|
.split(separator: " ").map(String.init)
|
|||
|
|
if crypto.recoverIdentity(from: words) {
|
|||
|
|
showRecovery = false
|
|||
|
|
recoveryInput = ""
|
|||
|
|
recoveryError = false
|
|||
|
|
} else {
|
|||
|
|
recoveryError = true
|
|||
|
|
}
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 6) {
|
|||
|
|
Image(systemName: "key.fill")
|
|||
|
|
Text(lang.isRu
|
|||
|
|
? "\u{0412}\u{043e}\u{0441}\u{0441}\u{0442}\u{0430}\u{043d}\u{043e}\u{0432}\u{0438}\u{0442}\u{044c}"
|
|||
|
|
: "Recover")
|
|||
|
|
}
|
|||
|
|
.frame(width: 180, height: 40)
|
|||
|
|
.background(gold)
|
|||
|
|
.foregroundColor(.black)
|
|||
|
|
.cornerRadius(8)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
extension Notification.Name {
|
|||
|
|
static let switchToSettingsTab = Notification.Name("switchToSettingsTab")
|
|||
|
|
static let switchToTab = Notification.Name("switchToTab")
|
|||
|
|
}
|