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:
2026-07-31 13:19:31 +06:00
co-authored by Claude Opus 5
commit cd53b6a1a1
20 changed files with 2897 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
import AppKit
import Combine
import Foundation
import SwiftUI
/// Single source of truth for both the menu bar item and the window, so the two
/// can never disagree about what is mounted or whether the tunnel is up.
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var state: LinkState = .unknown
/// share name -> mount point
@Published private(set) var mounted: [String: String] = [:]
@Published private(set) var busy = false
@Published private(set) var lastError: String?
/// Shares reported by the server itself. Empty until the first successful
/// enumeration.
@Published private(set) var discovered: [String] = []
/// Missing prerequisites — openvpn binary, profile, credentials.
@Published private(set) var requirements: [VPNService.Requirement] = []
/// Result of the last successful VPN action, for the window.
@Published private(set) var vpnMessage: String?
/// Zoom steps for ⌘+ / ⌘−, as an explicit multiplier for every font size and
/// padding.
///
/// Not `dynamicTypeSize`: that is an iOS mechanism and AppKit ignores it for
/// system fonts — measured, the window came out identical at every step.
/// Not `scaleEffect` either: it stretches an already rasterised layer and
/// blurs the text. Multiplying the metrics re-lays-out for real and stays
/// sharp.
static let zoomSteps: [CGFloat] = [0.8, 0.9, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0]
/// 1.15×, one step above system size — the default the app is actually
/// comfortable at. ⌘0 returns here, not to 1.0×.
private static let defaultZoomIndex = 3
private static let zoomKey = "zoomIndex"
@Published var zoomIndex: Int = {
let stored = UserDefaults.standard.object(forKey: AppModel.zoomKey) as? Int
return stored ?? AppModel.defaultZoomIndex
}() {
didSet { UserDefaults.standard.set(zoomIndex, forKey: Self.zoomKey) }
}
var zoomScale: CGFloat { Self.zoomSteps[min(max(zoomIndex, 0), Self.zoomSteps.count - 1)] }
func zoomIn() { zoomIndex = min(zoomIndex + 1, Self.zoomSteps.count - 1) }
func zoomOut() { zoomIndex = max(zoomIndex - 1, 0) }
func zoomReset() { zoomIndex = Self.defaultZoomIndex }
private(set) var config: Config
private var timer: Timer?
private var checking = false
/// Tracked separately from `state` so "just became reachable" can be told
/// apart from "still reachable" when deciding to auto-mount.
private var previousState: LinkState = .unknown
/// Enumeration needs credentials, so it fails until the user has
/// authenticated once. Without this flag an empty result would make every
/// poll spawn another smbutil — a failing probe every 30 s forever.
private var discoveryTried = false
/// Fires on a transition worth announcing. The first probe only counts if it
/// found a problem: being told at login that everything is fine is noise.
var onNotableChange: ((LinkState) -> Void)?
init(config: Config) {
self.config = config
}
/// What the UI lists. Discovery is the default so the app cannot silently
/// disagree with the server about which shares exist — the hand-written
/// list was the reason it showed four of eleven.
var shares: [String] {
let base = config.autoDiscoverShares && !discovered.isEmpty
? discovered
: config.shares
return base.filter { !config.excludeShares.contains($0) }
}
var host: String { config.nasHost }
func start() {
observeVolumeChanges()
refreshRequirements()
timer?.invalidate()
timer = Timer.scheduledTimer(
withTimeInterval: config.pollIntervalSeconds, repeats: true
) { [weak self] _ in
Task { @MainActor in self?.checkNow() }
}
checkNow()
}
/// Mounts happen behind this app's back — opening a share in Finder's server
/// browser mounts it on the spot. macOS announces those, so react to the
/// event instead of waiting out the poll interval.
private func observeVolumeChanges() {
let center = NSWorkspace.shared.notificationCenter
for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] {
center.addObserver(
forName: name, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.refreshMountedNow() }
}
}
}
/// Cheap: reads the kernel mount table, touches no network.
func refreshMountedNow() {
let live = Mounter.mountedShares(host: config.nasHost)
guard live != mounted else { return }
mounted = live
Log.write("mount table changed: \(live.keys.sorted().joined(separator: ", "))")
}
// MARK: - Probing
func checkNow() {
guard !checking else { return }
checking = true
Task { @MainActor in
let next = await probe()
self.mounted = Mounter.mountedShares(host: config.nasHost)
let wasUnknown = self.previousState == .unknown
let changed = next != self.previousState
let notable = changed && !(wasUnknown && next == .ok)
let becameReachable = next == .ok && self.previousState != .ok
if changed { Log.write("state: \(self.previousState.rawValue) -> \(next.rawValue)") }
self.previousState = next
self.state = next
self.checking = false
if notable { self.onNotableChange?(next) }
// Enumerate once per reconnect, not every poll — spawning smbutil
// on a 30 s timer to re-learn a list that rarely changes is waste.
if next == .ok && (becameReachable || !self.discoveryTried) {
await self.refreshShares()
}
if becameReachable && config.autoMountOnReconnect {
self.mount(config.autoMountShares, allowUI: false)
}
}
}
/// Explicit refresh always re-enumerates, unlike the polling path.
func refreshAll() {
Task { @MainActor in
await refreshShares()
checkNow()
}
}
func refreshShares() async {
guard config.autoDiscoverShares else { return }
discoveryTried = true
let found = await Mounter.listShares(
host: config.nasHost,
user: config.smbUser,
timeout: max(config.probeTimeoutSeconds, 8))
guard !found.isEmpty else { return }
discovered = found
Log.write("discovered \(found.count) shares: \(found.joined(separator: ", "))")
}
private func probe() async -> LinkState {
let vpnUp = await VPNService.isUp(config.vpn)
guard vpnUp else { return .vpnDown }
let nasUp = await Probe.tcpReachable(
host: config.nasHost,
port: config.nasPort,
timeout: config.probeTimeoutSeconds)
return nasUp ? .ok : .nasDown
}
// MARK: - Mounting
func isMounted(_ share: String) -> Bool { mounted[share] != nil }
func mountPoint(_ share: String) -> String? { mounted[share] }
/// `allowUI: false` is the silent path — it succeeds only if the Keychain
/// already holds the credentials. Automatic mounts always use it, so
/// reconnecting never throws an unexpected password sheet at the user.
func mount(_ shares: [String], allowUI: Bool) {
// Re-read the mount table instead of trusting the polled snapshot: it
// can be up to a poll interval stale, and Finder mounts shares behind
// our back.
let live = Mounter.mountedShares(host: config.nasHost)
mounted = live
let pending = shares.filter { live[$0] == nil }
guard !pending.isEmpty, !busy else { return }
busy = true
lastError = nil
let host = config.nasHost
let users = pending.map { config.user(for: $0) }
Task.detached(priority: .userInitiated) {
var results: [Mounter.Result] = []
for (share, user) in zip(pending, users) {
results.append(
Mounter.mount(host: host, user: user, share: share, allowUI: allowUI))
}
let collected = results
await MainActor.run { [weak self] in
self?.finish(collected)
}
}
}
private func finish(_ results: [Mounter.Result]) {
busy = false
let failed = results.filter { !$0.ok }
lastError = failed.isEmpty ? nil : failed.map(\.reason).joined(separator: "; ")
if let lastError { Log.write("mount failures: \(lastError)") }
// A successful interactive mount is what puts the credentials in the
// Keychain, so this is the moment enumeration can start working.
if results.contains(where: { $0.ok }) && discovered.isEmpty {
Task { @MainActor in await refreshShares() }
}
checkNow()
}
func mountMissing(allowUI: Bool) {
mount(shares, allowUI: allowUI)
}
func unmount(_ shares: [String]) {
let points = shares.compactMap { mounted[$0] }
guard !points.isEmpty, !busy else { return }
busy = true
Task.detached(priority: .userInitiated) {
for point in points {
_ = Mounter.unmount(mountPoint: point)
}
await MainActor.run { [weak self] in
self?.busy = false
self?.checkNow()
}
}
}
func toggle(_ share: String) {
if isMounted(share) {
unmount([share])
} else {
// Explicit click: allow the system auth sheet, since this is where
// the Keychain entry gets created.
mount([share], allowUI: true)
}
}
func openInFinder(_ share: String) {
if let point = mounted[share] {
NSWorkspace.shared.open(URL(fileURLWithPath: point))
} else {
mount([share], allowUI: true)
}
}
// MARK: - VPN
func connectVPN() { runVPN { await VPNService.connect(self.config.vpn) } }
func disconnectVPN() { runVPN { await VPNService.disconnect(self.config.vpn) } }
func rerouteVPN() { runVPN { await VPNService.reroute(self.config.vpn) } }
/// Serialised through `busy` so two privileged batches cannot race each
/// other into a half-connected state.
private func runVPN(_ action: @escaping @Sendable () async -> VPNService.ActionResult) {
guard !busy else { return }
busy = true
lastError = nil
Task { @MainActor in
let result = await action()
self.busy = false
self.lastError = result.isError ? result.message : nil
self.vpnMessage = result.isError ? nil : result.message
Log.write("vpn: \(result.message)")
self.checkNow()
}
}
/// Opens Terminal with a suggested fix (e.g. `brew install openvpn`) rather
/// than running it silently: an install command should be visible to the
/// person who asked for it, and its own output is worth seeing.
func runFixInTerminal(_ command: String) {
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = """
tell application "Terminal"
activate
do script "\(escaped)"
end tell
"""
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
do {
try process.run()
} catch {
Log.write("failed to launch Terminal: \(error)")
}
}
func openVPNLog() {
NSWorkspace.shared.open(URL(fileURLWithPath: config.vpn.logPath))
}
// MARK: - Settings
/// Persists the edited config and re-reads everything that depends on it.
func updateVPN(_ vpn: VPNConfig) {
config.vpn = vpn
config.write()
Log.write("vpn config updated: profile=\(vpn.configPath) "
+ "server=\(vpn.serverHost) keychain=\(vpn.keychainService)")
refreshRequirements()
checkNow()
}
func updateSMBUser(_ user: String) {
let trimmed = user.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, trimmed != config.smbUser else { return }
config.smbUser = trimmed
config.write()
checkNow()
}
/// Stores the VPN password. The value is handed straight to the Keychain and
/// kept nowhere else — not in the config file, not in the log.
func saveVPNPassword(_ password: String) -> String? {
guard !password.isEmpty else { return "Пароль пустой" }
let error = KeychainStore.save(
service: config.vpn.keychainService,
account: config.vpn.keychainAccount,
password: password)
Log.write(error == nil
? "keychain: entry \(config.vpn.keychainService) saved"
: "keychain: save failed — \(error!)")
refreshRequirements()
return error
}
func vpnPasswordStored() -> Bool {
KeychainStore.exists(
service: config.vpn.keychainService,
account: config.vpn.keychainAccount)
}
func refreshRequirements() {
Task { @MainActor in
requirements = await VPNService.requirements(config.vpn)
}
}
func openConfig() {
config.writeIfAbsent()
NSWorkspace.shared.open(Config.configURL)
}
}