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