montana/macOS/MontanaPresence/MontanaPresenceApp.swift

286 lines
11 KiB
Swift
Raw Normal View History

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