286 lines
11 KiB
Swift
286 lines
11 KiB
Swift
|
|
import SwiftUI
|
|||
|
|
import ServiceManagement
|
|||
|
|
import IOKit
|
|||
|
|
import Combine
|
|||
|
|
|
|||
|
|
@main
|
|||
|
|
struct MontanaPresenceApp: App {
|
|||
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|||
|
|
|
|||
|
|
var body: some Scene {
|
|||
|
|
// Settings scene doesn't create windows - perfect for menu bar apps
|
|||
|
|
Settings {
|
|||
|
|
EmptyView()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@MainActor
|
|||
|
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
|
|
var statusItem: NSStatusItem!
|
|||
|
|
var mainWindow: NSWindow?
|
|||
|
|
private var cancellables = Set<AnyCancellable>()
|
|||
|
|
|
|||
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|||
|
|
// ╔══════════════════════════════════════════════════════════════════╗
|
|||
|
|
// ║ SINGLE INSTANCE ENFORCEMENT ║
|
|||
|
|
// ║ 1 устройство = 1 Montana. Строго. ║
|
|||
|
|
// ║ Автоматически убивает все дубликаты. ║
|
|||
|
|
// ╚══════════════════════════════════════════════════════════════════╝
|
|||
|
|
terminateOtherInstances()
|
|||
|
|
|
|||
|
|
// ╔══════════════════════════════════════════════════════════════════╗
|
|||
|
|
// ║ DEVICE ID LOCK ║
|
|||
|
|
// ║ Привязка к hardware UUID. Строго. ║
|
|||
|
|
// ╚══════════════════════════════════════════════════════════════════╝
|
|||
|
|
checkDeviceBinding()
|
|||
|
|
|
|||
|
|
setupStatusItem()
|
|||
|
|
setupMainWindow()
|
|||
|
|
|
|||
|
|
// Show window on launch
|
|||
|
|
mainWindow?.makeKeyAndOrderFront(nil)
|
|||
|
|
NSApp.activate(ignoringOtherApps: true)
|
|||
|
|
|
|||
|
|
// Menu bar label syncs directly from tick() — same call stack, zero lag
|
|||
|
|
PresenceEngine.shared.onTick = { [weak self] in
|
|||
|
|
self?.updateLabel()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update menu bar when identity changes (logout → show only Ɉ)
|
|||
|
|
CryptoManager.shared.$hasIdentity
|
|||
|
|
.receive(on: RunLoop.main)
|
|||
|
|
.sink { [weak self] _ in
|
|||
|
|
self?.updateLabel()
|
|||
|
|
}
|
|||
|
|
.store(in: &cancellables)
|
|||
|
|
|
|||
|
|
Task { @MainActor in
|
|||
|
|
UpdateManager.shared.startChecking()
|
|||
|
|
// Трекинг стартует ТОЛЬКО если есть ключи (идентичность)
|
|||
|
|
// Без ключей — нет адреса — нет трекинга — нет монет
|
|||
|
|
// CryptoManager.syncAddressToEngine() запустит трекинг при создании/загрузке ключей
|
|||
|
|
if CryptoManager.shared.hasIdentity {
|
|||
|
|
PresenceEngine.shared.startTracking()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Dock / Launchpad icon click — ALWAYS show window
|
|||
|
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
|||
|
|
guard let window = mainWindow else { return false }
|
|||
|
|
if window.isMiniaturized {
|
|||
|
|
window.deminiaturize(nil)
|
|||
|
|
}
|
|||
|
|
window.makeKeyAndOrderFront(nil)
|
|||
|
|
NSApp.activate(ignoringOtherApps: true)
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Single Instance Protection (NSRunningApplication API)
|
|||
|
|
|
|||
|
|
private func terminateOtherInstances() {
|
|||
|
|
let bundleID = Bundle.main.bundleIdentifier!
|
|||
|
|
let currentPID = ProcessInfo.processInfo.processIdentifier
|
|||
|
|
|
|||
|
|
let runningInstances = NSWorkspace.shared.runningApplications.filter {
|
|||
|
|
$0.bundleIdentifier == bundleID && $0.processIdentifier != currentPID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !runningInstances.isEmpty {
|
|||
|
|
print("⚠️ Found \(runningInstances.count) other Montana instances. Terminating...")
|
|||
|
|
for app in runningInstances {
|
|||
|
|
print(" Killing PID \(app.processIdentifier)")
|
|||
|
|
app.terminate()
|
|||
|
|
// Force kill if terminate() doesn't work
|
|||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|||
|
|
if app.isActive {
|
|||
|
|
app.forceTerminate()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
// MARK: - Device Binding
|
|||
|
|
|
|||
|
|
private func getHardwareUUID() -> String? {
|
|||
|
|
let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
|||
|
|
guard platformExpert != 0 else { return nil }
|
|||
|
|
defer { IOObjectRelease(platformExpert) }
|
|||
|
|
|
|||
|
|
guard let serialNumberAsCFString = IORegistryEntryCreateCFProperty(
|
|||
|
|
platformExpert,
|
|||
|
|
kIOPlatformUUIDKey as CFString,
|
|||
|
|
kCFAllocatorDefault,
|
|||
|
|
0
|
|||
|
|
)?.takeRetainedValue() as? String else {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return serialNumberAsCFString
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func checkDeviceBinding() {
|
|||
|
|
guard let currentDeviceID = getHardwareUUID() else {
|
|||
|
|
// Cannot get hardware UUID — allow (fallback)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let savedDeviceID = UserDefaults.standard.string(forKey: "montana_device_id")
|
|||
|
|
|
|||
|
|
if let saved = savedDeviceID {
|
|||
|
|
// Device already bound — check if matches
|
|||
|
|
if saved != currentDeviceID {
|
|||
|
|
let alert = NSAlert()
|
|||
|
|
alert.messageText = "Montana привязан к другому устройству"
|
|||
|
|
alert.informativeText = """
|
|||
|
|
Montana Protocol строго привязан к устройству при первом запуске.
|
|||
|
|
|
|||
|
|
Это приложение настроено для другого Mac.
|
|||
|
|
|
|||
|
|
Если вы хотите использовать Montana на этом устройстве:
|
|||
|
|
1. Экспортируйте кошелёк (адрес + ключи) на старом устройстве
|
|||
|
|
2. Удалите Montana.app
|
|||
|
|
3. Переустановите Montana на этом Mac
|
|||
|
|
4. Импортируйте кошелёк
|
|||
|
|
|
|||
|
|
Device ID (этот Mac): \(currentDeviceID.prefix(16))...
|
|||
|
|
Device ID (привязан): \(saved.prefix(16))...
|
|||
|
|
"""
|
|||
|
|
alert.alertStyle = .critical
|
|||
|
|
alert.addButton(withTitle: "Закрыть")
|
|||
|
|
alert.runModal()
|
|||
|
|
NSApp.terminate(nil)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// First run — bind to this device
|
|||
|
|
UserDefaults.standard.set(currentDeviceID, forKey: "montana_device_id")
|
|||
|
|
print("Montana bound to device: \(currentDeviceID)")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func setupStatusItem() {
|
|||
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|||
|
|
if let button = statusItem.button {
|
|||
|
|
button.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
|
|||
|
|
button.title = "\u{0248}"
|
|||
|
|
button.target = self
|
|||
|
|
button.action = #selector(handleClick(_:))
|
|||
|
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|||
|
|
}
|
|||
|
|
updateLabel()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func setupMainWindow() {
|
|||
|
|
let window = NSWindow(
|
|||
|
|
contentRect: NSRect(x: 0, y: 0, width: 800, height: 650),
|
|||
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|||
|
|
backing: .buffered,
|
|||
|
|
defer: false
|
|||
|
|
)
|
|||
|
|
window.title = "Montana Protocol Ɉ"
|
|||
|
|
window.center()
|
|||
|
|
window.setFrameAutosaveName("MontanaMainWindow")
|
|||
|
|
window.isReleasedWhenClosed = false
|
|||
|
|
window.appearance = NSAppearance(named: .darkAqua)
|
|||
|
|
window.minSize = NSSize(width: 600, height: 500)
|
|||
|
|
|
|||
|
|
let contentView = MainWindowView()
|
|||
|
|
.environmentObject(PresenceEngine.shared)
|
|||
|
|
.environmentObject(UpdateManager.shared)
|
|||
|
|
.environmentObject(VPNManager.shared)
|
|||
|
|
.environmentObject(CryptoManager.shared)
|
|||
|
|
.environmentObject(LanguageManager.shared)
|
|||
|
|
|
|||
|
|
window.contentViewController = NSHostingController(rootView: contentView)
|
|||
|
|
mainWindow = window
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@MainActor
|
|||
|
|
private func updateLabel() {
|
|||
|
|
// No identity = only Ɉ symbol, nothing else
|
|||
|
|
guard CryptoManager.shared.hasIdentity else {
|
|||
|
|
statusItem.button?.title = "\u{0248}"
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let engine = PresenceEngine.shared
|
|||
|
|
var parts: [String] = []
|
|||
|
|
if engine.showBalanceInMenuBar {
|
|||
|
|
let formatter = NumberFormatter()
|
|||
|
|
formatter.numberStyle = .decimal
|
|||
|
|
formatter.groupingSeparator = ","
|
|||
|
|
let s = formatter.string(from: NSNumber(value: engine.displayBalance)) ?? "\(engine.displayBalance)"
|
|||
|
|
parts.append(s)
|
|||
|
|
}
|
|||
|
|
if engine.showSymbolInMenuBar {
|
|||
|
|
parts.append("\u{0248}")
|
|||
|
|
}
|
|||
|
|
// Always show at least Ɉ so status item remains clickable
|
|||
|
|
statusItem.button?.title = parts.isEmpty ? "\u{0248}" : parts.joined(separator: " ")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Left click → main window, Right click → context menu
|
|||
|
|
@objc func handleClick(_ sender: NSStatusBarButton) {
|
|||
|
|
guard let event = NSApp.currentEvent else { return }
|
|||
|
|
if event.type == .rightMouseUp {
|
|||
|
|
showContextMenu()
|
|||
|
|
} else {
|
|||
|
|
toggleMainWindow()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func toggleMainWindow() {
|
|||
|
|
guard let window = mainWindow else { return }
|
|||
|
|
if window.isVisible {
|
|||
|
|
window.orderOut(nil)
|
|||
|
|
} else {
|
|||
|
|
window.makeKeyAndOrderFront(nil)
|
|||
|
|
NSApp.activate(ignoringOtherApps: true)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func showContextMenu() {
|
|||
|
|
let menu = NSMenu()
|
|||
|
|
|
|||
|
|
let settingsItem = NSMenuItem(title: "\u{041d}\u{0430}\u{0441}\u{0442}\u{0440}\u{043e}\u{0439}\u{043a}\u{0438}", action: #selector(openSettings), keyEquivalent: ",")
|
|||
|
|
settingsItem.target = self
|
|||
|
|
menu.addItem(settingsItem)
|
|||
|
|
|
|||
|
|
let aboutItem = NSMenuItem(title: "\u{041e} \u{043f}\u{0440}\u{043e}\u{0433}\u{0440}\u{0430}\u{043c}\u{043c}\u{0435}", action: #selector(showAbout), keyEquivalent: "")
|
|||
|
|
aboutItem.target = self
|
|||
|
|
menu.addItem(aboutItem)
|
|||
|
|
|
|||
|
|
menu.addItem(NSMenuItem.separator())
|
|||
|
|
|
|||
|
|
let quitItem = NSMenuItem(title: "\u{0412}\u{044b}\u{0439}\u{0442}\u{0438}", action: #selector(quitApp), keyEquivalent: "q")
|
|||
|
|
quitItem.target = self
|
|||
|
|
menu.addItem(quitItem)
|
|||
|
|
|
|||
|
|
statusItem.menu = menu
|
|||
|
|
statusItem.button?.performClick(nil)
|
|||
|
|
statusItem.menu = nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@objc func openSettings() {
|
|||
|
|
guard let window = mainWindow else { return }
|
|||
|
|
window.makeKeyAndOrderFront(nil)
|
|||
|
|
NSApp.activate(ignoringOtherApps: true)
|
|||
|
|
// Switch to Settings tab
|
|||
|
|
NotificationCenter.default.post(name: .switchToSettingsTab, object: nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@objc func showAbout() {
|
|||
|
|
NSApp.activate(ignoringOtherApps: true)
|
|||
|
|
NSApp.orderFrontStandardAboutPanel(nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@objc func quitApp() {
|
|||
|
|
PresenceEngine.shared.stopTracking()
|
|||
|
|
NSApp.terminate(nil)
|
|||
|
|
}
|
|||
|
|
}
|