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.. 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") }