montana/macOS/MontanaPresence/PresenceEngine.swift

259 lines
9.1 KiB
Swift
Raw Normal View History

import Foundation
import Combine
import AppKit
@MainActor
class PresenceEngine: ObservableObject {
static let shared = PresenceEngine()
@Published var isTracking = false
@Published var isPresent = false
@Published var sessionSeconds: Int = 0
@Published var pendingSeconds: Int = 0
@Published var serverBalance: Int = 0
@Published var isOnline = false
@Published var networkNodes: [(name: String, location: String, online: Bool)] = []
@Published var networkHealth: String = "0%"
@Published var networkOnline: Int = 0
@Published var networkTotal: Int = 3
@Published var protocolVersion: String = "2.0.0"
@Published var protocolMode: String = "MAINNET"
@Published var protocolCrypto: String = "ML-DSA-65 (FIPS 204)"
@Published var ledgerVerified: Bool = false
@Published var ledgerBalance: Int = 0
@Published var walletSynced: Bool = false
@Published var t2BlockNumber: Int = 0
@Published var t2SecondsElapsed: Int = 0
@Published var t2TrackingSeconds: Int = 0
@Published var showSymbolInMenuBar: Bool = true
@Published var showBalanceInMenuBar: Bool = false
@Published var genesisDate: Date = {
var c = DateComponents()
c.year = 2026; c.month = 1; c.day = 9; c.hour = 0; c.minute = 0
return Calendar.current.date(from: c) ?? Date()
}()
var displayBalance: Int { serverBalance + pendingSeconds }
/// Callback fired AFTER pendingSeconds is updated in tick() for menu bar sync
var onTick: (() -> Void)?
// GENESIS ECONOMICS
// Montana Genesis: 09.01.2026 network launch
// Price Genesis: 12.03.2021 BIPL (Bill Payment) price anchor
//
// Model: 1 second of human presence = 1 Ɉ
// BIPL price: 1 second = $0.1605 USD
// RUB rate at price genesis (12.03.2021): ~75 /$
// 1 Ɉ = $0.1605 = 12.04
//
// Source: https://x.com/tojesatoshi/status/2012823709858275473
//
static let genesisPriceUSD: Double = 0.1605 // $0.1605 per Ɉ (BIPL)
static let genesisPriceRUB: Double = 12.04 // 12.04 per Ɉ ($0.1605 × 75 /$)
static let genesisSettlementDate = "12.03.2021"
var balanceUSD: Double { Double(displayBalance) * Self.genesisPriceUSD }
var balanceRUB: Double { Double(displayBalance) * Self.genesisPriceRUB }
var epochDays: Int {
let diff = Calendar.current.dateComponents([.day], from: genesisDate, to: Date())
return max(diff.day ?? 0, 0)
}
var epochNumber: Int { epochDays / 7 + 1 }
let t2Duration = 600
var t2SecondsRemaining: Int { max(t2Duration - t2SecondsElapsed, 0) }
var t2Progress: Double { min(Double(t2SecondsElapsed) / Double(t2Duration), 1.0) }
var t2PendingCoins: Int { t2TrackingSeconds }
/// Available balance = everything confirmed BEFORE current T2 window
var availableBalance: Int { max(displayBalance - t2PendingCoins, 0) }
private var tickTimer: Timer?
private var reportTimer: Timer?
private var syncTimer: Timer?
private var tickCount = 0
private let pendingKey = "montana_presence_pending"
private let balanceKey = "montana_presence_balance"
let api = MontanaAPIClient()
private init() {
pendingSeconds = UserDefaults.standard.integer(forKey: pendingKey)
serverBalance = UserDefaults.standard.integer(forKey: balanceKey)
showSymbolInMenuBar = UserDefaults.standard.object(forKey: "menubar_symbol") as? Bool ?? true
showBalanceInMenuBar = UserDefaults.standard.object(forKey: "menubar_balance") as? Bool ?? false
updateT2()
}
func toggleMenuBarSymbol() {
showSymbolInMenuBar.toggle()
UserDefaults.standard.set(showSymbolInMenuBar, forKey: "menubar_symbol")
}
func toggleMenuBarBalance() {
showBalanceInMenuBar.toggle()
UserDefaults.standard.set(showBalanceInMenuBar, forKey: "menubar_balance")
}
var address: String? {
get { UserDefaults.standard.string(forKey: "montana_address") }
set { UserDefaults.standard.set(newValue, forKey: "montana_address") }
}
var walletNumber: Int {
UserDefaults.standard.integer(forKey: "wallet_number")
}
var displayAddress: String {
let num = walletNumber
if num > 0 { return "\u{0248}-\(num)" }
if let addr = address { return String(addr.prefix(8)) + "..." + String(addr.suffix(4)) }
return ""
}
func autoStart() {
guard let addr = address, !addr.isEmpty, !isTracking else { return }
if walletNumber == 0 {
Task {
do {
let wallet = try await api.registerAgentWallet(address: addr)
UserDefaults.standard.set(wallet.number, forKey: "wallet_number")
} catch {}
}
}
startTracking()
}
func startTracking() {
guard !isTracking else { return }
isTracking = true
isPresent = true
sessionSeconds = 0
t2TrackingSeconds = 0
tickTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tick() }
}
reportTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
Task { @MainActor in await self?.reportToServer() }
}
syncTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
Task { @MainActor in await self?.syncBalance() }
}
Task { await syncBalance() }
}
func stopTracking() {
isTracking = false
isPresent = false
tickTimer?.invalidate()
tickTimer = nil
reportTimer?.invalidate()
reportTimer = nil
syncTimer?.invalidate()
syncTimer = nil
Task { await reportToServer() }
}
/// Full reset called on logout. Zeroes everything.
func fullReset() {
stopTracking()
serverBalance = 0
pendingSeconds = 0
sessionSeconds = 0
tickCount = 0
isOnline = false
walletSynced = false
ledgerVerified = false
ledgerBalance = 0
t2TrackingSeconds = 0
address = nil
UserDefaults.standard.set(0, forKey: pendingKey)
UserDefaults.standard.set(0, forKey: balanceKey)
UserDefaults.standard.removeObject(forKey: "wallet_number")
}
// 1 second = 1 Ɉ. Always.
private func tick() {
guard isTracking else { return }
sessionSeconds += 1
pendingSeconds += 1
tickCount += 1
updateT2()
t2TrackingSeconds += 1
if tickCount % 10 == 0 {
UserDefaults.standard.set(pendingSeconds, forKey: pendingKey)
}
onTick?()
}
func updateT2() {
let secs = max(Int(Date().timeIntervalSince(genesisDate)), 0)
let newBlock = secs / t2Duration
if newBlock != t2BlockNumber {
t2TrackingSeconds = 0
}
t2BlockNumber = newBlock
t2SecondsElapsed = secs % t2Duration
}
func reportToServer() async {
guard let addr = address, !addr.isEmpty, pendingSeconds > 0 else { return }
let delta = pendingSeconds
do {
let newBalance = try await api.reportPresence(address: addr, seconds: delta)
let expectedMin = serverBalance + delta
serverBalance = max(newBalance, expectedMin)
pendingSeconds -= delta
if pendingSeconds < 0 { pendingSeconds = 0 }
isOnline = true
walletSynced = pendingSeconds == 0
UserDefaults.standard.set(pendingSeconds, forKey: pendingKey)
UserDefaults.standard.set(serverBalance, forKey: balanceKey)
} catch {
serverBalance += delta
pendingSeconds -= delta
if pendingSeconds < 0 { pendingSeconds = 0 }
isOnline = false
UserDefaults.standard.set(pendingSeconds, forKey: pendingKey)
UserDefaults.standard.set(serverBalance, forKey: balanceKey)
}
}
func syncBalance() async {
guard let addr = address, !addr.isEmpty else { return }
if pendingSeconds == 0 {
do {
let balance = try await api.fetchBalance(address: addr)
serverBalance = max(balance, serverBalance)
isOnline = true
walletSynced = true
UserDefaults.standard.set(serverBalance, forKey: balanceKey)
} catch {
isOnline = false
walletSynced = false
}
}
do {
let (net, proto) = try await api.fetchStatus()
networkNodes = net.nodes
networkOnline = net.onlineCount
networkTotal = net.totalNodes
networkHealth = net.health
protocolVersion = proto.version
protocolMode = proto.mode
protocolCrypto = proto.crypto
} catch {}
do {
let verify = try await api.fetchLedgerVerify(address: addr)
ledgerVerified = verify.verified
ledgerBalance = verify.ledgerBalance
} catch {}
}
}