304 lines
11 KiB
Swift
304 lines
11 KiB
Swift
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<ContentView>?
|
|
private var settingsWindow: NSWindow?
|
|
private var keyMonitor: Any?
|
|
private var wakeObserver: NSObjectProtocol?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
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)")
|
|
}
|
|
}
|
|
}
|
|
}
|