import AppKit import Combine import ServiceManagement import SwiftUI import UserNotifications @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private var statusItem: NSStatusItem! private var model: AppModel! private var window: NSWindow? private var hosting: NSHostingController? private var settingsWindow: NSWindow? private var keyMonitor: Any? private var wakeObserver: NSObjectProtocol? private var cancellables = Set() func applicationDidFinishLaunching(_ notification: Notification) { let config = Config.load() config.writeIfAbsent() statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem.menu = NSMenu() statusItem.isVisible = true statusItem.behavior = [] // Survives across launches so macOS restores the same slot instead of // treating every rebuild as a brand new item. statusItem.autosaveName = "CampusNASStatusItem" requestNotificationAuthorization() model = AppModel(config: config) model.onNotableChange = { [weak self] state in self?.postNotification(state.notificationBody) } // The menu bar item mirrors the model, same as the window does. model.objectWillChange .receive(on: RunLoop.main) .sink { [weak self] _ in self?.render() } .store(in: &cancellables) installKeyMonitor() observeWake() model.start() render() if let button = statusItem.button { Log.write("status item: visible=\(statusItem.isVisible) " + "width=\(button.frame.width) hasImage=\(button.image != nil) " + "screens=\(NSScreen.screens.count)") } else { Log.write("status item has no button — menu bar is full") } } private func observeWake() { wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in // NetworkExtension and Wi-Fi routes settle just after didWake. DispatchQueue.main.asyncAfter(deadline: .now() + 2) { Task { @MainActor in self?.model.wake() } } } } /// Launching the app again while it is already running (Finder, Spotlight, /// `open`) brings the window up instead of doing nothing, which is what a /// second launch is normally asking for. func applicationShouldHandleReopen( _ sender: NSApplication, hasVisibleWindows: Bool ) -> Bool { showWindow() return true } // MARK: - Status item private func render() { let state = model.state if let button = statusItem.button { let image = NSImage( systemSymbolName: state.symbolName, accessibilityDescription: state.title) image?.isTemplate = true button.image = image // Without this fallback an unresolved SF Symbol leaves the item with // no image and no title, i.e. zero width — present but invisible and // unclickable, which is indistinguishable from the app not running. if image == nil { Log.write("SF Symbol missing: \(state.symbolName)") } let label = image == nil ? (state.badge.isEmpty ? "NAS" : state.badge) : state.badge button.imagePosition = label.isEmpty ? .imageOnly : .imageLeading button.title = label.isEmpty ? "" : " " + label button.toolTip = state.title } let menu = NSMenu() let status = NSMenuItem(title: state.title, action: nil, keyEquivalent: "") status.isEnabled = false menu.addItem(status) let mountedCount = model.mounted.count let summary = NSMenuItem( title: "Смонтировано шар: \(mountedCount) из \(model.shares.count)", action: nil, keyEquivalent: "") summary.isEnabled = false menu.addItem(summary) menu.addItem(.separator()) menu.addItem(action("Показать окно", #selector(showWindow), key: "o")) menu.addItem(action("Проверить сейчас", #selector(checkNow), key: "r")) menu.addItem(.separator()) menu.addItem(action("Крупнее (⌘+)", #selector(zoomIn))) menu.addItem(action("Мельче (⌘−)", #selector(zoomOut))) menu.addItem(action("Обычный размер (⌘0)", #selector(zoomReset))) menu.addItem(.separator()) let launchItem = NSMenuItem( title: "Запускать при входе", action: #selector(toggleLoginItem), keyEquivalent: "") launchItem.state = (SMAppService.mainApp.status == .enabled) ? .on : .off launchItem.target = self menu.addItem(launchItem) menu.addItem(action("Настройки…", #selector(showSettings), key: ",")) menu.addItem(action("Открыть конфиг", #selector(openConfig))) menu.addItem(.separator()) menu.addItem(NSMenuItem( title: "Выйти", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) statusItem.menu = menu } private func action(_ title: String, _ selector: Selector, key: String = "") -> NSMenuItem { let item = NSMenuItem(title: title, action: selector, keyEquivalent: key) item.target = self return item } // MARK: - Window @objc private func showWindow() { if window == nil { let hosting = NSHostingController( rootView: ContentView(model: model) { [weak self] in self?.showSettings() }) self.hosting = hosting let win = NSWindow(contentViewController: hosting) win.title = "CampusNAS" win.styleMask = [.titled, .closable, .miniaturizable, .resizable] // The delegate is an accessory app, so the window is closed and // reopened rather than destroyed — keep the instance alive. win.isReleasedWhenClosed = false win.delegate = self win.center() window = win } // An .accessory app is not activated by default; without this the // window opens behind whatever is in front. NSApp.activate(ignoringOtherApps: true) window?.makeKeyAndOrderFront(nil) model.checkNow() } /// Rebuilt on each open so the editor starts from the current config rather /// than whatever was on screen last time. @objc private func showSettings() { settingsWindow?.close() let hosting = NSHostingController( rootView: SettingsView(model: model) { [weak self] in self?.settingsWindow?.close() }) let win = NSWindow(contentViewController: hosting) win.title = "Настройки CampusNAS" win.styleMask = [.titled, .closable, .resizable] win.isReleasedWhenClosed = false win.center() settingsWindow = win NSApp.activate(ignoringOtherApps: true) win.makeKeyAndOrderFront(nil) } // MARK: - Zoom /// An .accessory app has no menu bar, so ⌘+ / ⌘− / ⌘0 cannot come from menu /// items — they are caught here while our window is key. private func installKeyMonitor() { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self, event.modifierFlags.contains(.command), self.window?.isKeyWindow == true else { return event } switch event.charactersIgnoringModifiers { case "+", "=": self.model.zoomIn() self.fitWindowToContent() return nil case "-": self.model.zoomOut() self.fitWindowToContent() return nil case "0": self.model.zoomReset() self.fitWindowToContent() return nil default: return event } } } /// Resizes after SwiftUI has re-laid-out at the new type size — reading /// fittingSize in the same turn returns the previous layout. private func fitWindowToContent() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let window = self?.window, let hosting = self?.hosting else { return } let size = hosting.view.fittingSize guard size.width > 0, size.height > 0 else { return } window.setContentSize(size) } } @objc private func zoomIn() { model.zoomIn(); fitWindowToContent() } @objc private func zoomOut() { model.zoomOut(); fitWindowToContent() } @objc private func zoomReset() { model.zoomReset(); fitWindowToContent() } // MARK: - Actions @objc private func checkNow() { model.checkNow() } @objc private func openConfig() { model.openConfig() } @objc private func toggleLoginItem() { do { if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() } else { try SMAppService.mainApp.register() } } catch { Log.write("login item toggle failed: \(error)") } render() } // MARK: - Notifications private func requestNotificationAuthorization() { guard let bundleID = Bundle.main.bundleIdentifier else { Log.write("no bundle identifier, notifications disabled") return } Log.write("bundle \(bundleID) at \(Bundle.main.bundlePath)") UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound]) { granted, error in if let error = error as NSError? { Log.write("notification auth error: domain=\(error.domain) " + "code=\(error.code) \(error.localizedDescription)") } else if !granted { Log.write("notifications denied") } else { Log.write("notifications authorized") } } } /// Posts unconditionally rather than gating on the authorization result: /// gating meant a missed permission prompt silenced the app forever. Under /// ad-hoc signing this never actually delivers — the menu bar badge is the /// real indicator — but the code costs nothing and starts working if the /// app is ever signed with a Developer ID. private func postNotification(_ body: String) { guard Bundle.main.bundleIdentifier != nil, !body.isEmpty else { return } let content = UNMutableNotificationContent() content.title = "campusnas" content.body = body content.sound = .default let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) { error in if let error = error as NSError? { Log.write("notification post failed: code=\(error.code)") } } } }