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
+237
View File
@@ -0,0 +1,237 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var model: AppModel
/// Injected rather than owned: window management belongs to the delegate.
var onOpenSettings: () -> Void = {}
/// Zoom multiplier. Every size below is expressed in terms of it, which is
/// what makes ⌘+ / ⌘− an actual re-layout instead of a stretched bitmap.
private var s: CGFloat { model.zoomScale }
private func font(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
.system(size: size * s, weight: weight)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
if !missingRequirements.isEmpty {
requirementsSection
Divider()
}
vpnSection
Divider()
sharesSection
Divider()
footer
}
// Width is a floor, not a fixture: larger zoom steps need more room,
// and the window is resizable.
.frame(minWidth: 430 * s)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 10 * s) {
Image(systemName: model.state.symbolName)
.font(.system(size: 22 * s))
.foregroundStyle(tint)
.frame(width: 28 * s)
VStack(alignment: .leading, spacing: 2 * s) {
Text(model.state.title)
.font(font(13, .semibold))
Text(model.host)
.font(font(11))
.foregroundStyle(.secondary)
}
Spacer()
if model.busy {
ProgressView().controlSize(.small)
}
}
.padding(12 * s)
}
private var tint: Color {
switch model.state {
case .ok: return .green
case .nasDown: return .orange
case .vpnDown: return .red
case .unknown: return .secondary
}
}
// MARK: - Requirements
/// Only unmet requirements are listed. A permanent green checklist of things
/// that are fine is noise; a missing dependency is not.
private var missingRequirements: [VPNService.Requirement] {
model.requirements.filter { !$0.satisfied }
}
private var requirementsSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Не хватает для запуска VPN")
.font(font(12, .medium))
.foregroundStyle(.orange)
ForEach(missingRequirements) { requirement in
HStack(alignment: .firstTextBaseline, spacing: 8 * s) {
VStack(alignment: .leading, spacing: 1 * s) {
Text(requirement.title).font(font(12))
Text(requirement.detail)
.font(font(10))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
if let fix = requirement.fix {
Button("Установить") { model.runFixInTerminal(fix) }
.buttonStyle(ScaledButtonStyle(scale: s))
}
}
}
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 10 * s)
}
// MARK: - VPN
private var vpnSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
HStack(spacing: 8 * s) {
Text("VPN")
.font(font(12, .medium))
.frame(width: 40 * s, alignment: .leading)
Button("Подключить") { model.connectVPN() }
.disabled(model.busy || model.state.isTunnelUp)
Button("Отключить") { model.disconnectVPN() }
.disabled(model.busy || !model.state.isTunnelUp)
Button("Маршрут") { model.rerouteVPN() }
.disabled(model.busy)
Button("Лог") { model.openVPNLog() }
Spacer()
}
.buttonStyle(ScaledButtonStyle(scale: s))
if let message = model.vpnMessage {
Text(message)
.font(font(10))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 10 * s)
}
// MARK: - Shares
private var sharesSection: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Шары")
.font(font(12, .medium))
Spacer()
Text("смонтировано \(model.mounted.count) из \(model.shares.count)")
.font(font(11))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12 * s)
.padding(.top, 10 * s)
.padding(.bottom, 6 * s)
// The list is whatever the server reports, so it can be long —
// scroll rather than growing the window past the screen.
MeasuredScrollView(minHeight: 48 * s, maxHeight: 420 * s) {
VStack(alignment: .leading, spacing: 0) {
ForEach(model.shares, id: \.self) { share in
shareRow(share)
if share != model.shares.last {
Divider().padding(.leading, 12 * s)
}
}
}
}
}
.padding(.bottom, 8 * s)
}
private func shareRow(_ share: String) -> some View {
let isMounted = model.isMounted(share)
return HStack(spacing: 8 * s) {
Circle()
.fill(isMounted ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 8 * s, height: 8 * s)
VStack(alignment: .leading, spacing: 1 * s) {
Text(share)
.font(font(13))
Text(model.mountPoint(share) ?? "не подключена")
.font(font(10))
.foregroundStyle(.secondary)
}
Spacer()
if isMounted {
Button("Открыть") { model.openInFinder(share) }
.buttonStyle(ScaledButtonStyle(scale: s, borderless: true))
}
Button(isMounted ? "Отключить" : "Подключить") {
model.toggle(share)
}
.buttonStyle(ScaledButtonStyle(scale: s))
.disabled(model.busy || (!isMounted && model.state != .ok))
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 6 * s)
}
// MARK: - Footer
private var footer: some View {
VStack(alignment: .leading, spacing: 8 * s) {
if let error = model.lastError {
Text(error)
.font(font(11))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8 * s) {
Button("Подключить все") { model.mountMissing(allowUI: true) }
.disabled(model.busy || model.state != .ok)
.fixedSize()
Button("Отключить все") { model.unmount(model.shares) }
.disabled(model.busy || model.mounted.isEmpty)
.fixedSize()
Spacer()
Button("Настройки") { onOpenSettings() }
.fixedSize()
Button("Обновить") { model.refreshAll() }
.fixedSize()
}
.buttonStyle(ScaledButtonStyle(scale: s))
}
.padding(12 * s)
}
}