montana/macOS/MontanaPresence/MainWindowView.swift

701 lines
29 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
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")
}