montana/macOS/MontanaPresence/MainWindowView.swift

701 lines
29 KiB
Swift
Raw Permalink Normal View History

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