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