Files
ovpn/Sources/CampusNASMonitor/AppDelegate.swift
T
romanvandClaude Opus 5 cd53b6a1a1 Add CampusNAS menu bar agent
Native macOS agent that keeps the office VPN and the NAS shares working
without the shell wrapper it replaces.

- VPN: full port of the `ovpn` script — host-route pinning, gateway
  discovery via networksetup, tunnel liveness by address plus pushed
  route, startup grace, restart of a wedged daemon. Root steps are
  batched into one system authorization prompt.
- SMB: mounts via NetFS, discovers shares from the server, detects
  mounts event-driven through NSWorkspace, matches the host by resolved
  alias so externally created mounts are recognised.
- Credentials never pass through the process: the VPN auth file is
  written by `security` straight to disk, and the password is stored via
  SecItemAdd rather than a command line visible in ps.
- UI: status window, settings editor, ⌘+/⌘− zoom that re-lays out rather
  than scaling a bitmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:19:31 +06:00

291 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 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()
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")
}
}
/// 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)")
}
}
}
}