montana/macOS/MontanaPresence/PresenceEngine.swift

259 lines
9.1 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 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 {}
}
}