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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user