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>
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user