Files
ovpn/Sources/CampusNASMonitor/SettingsView.swift
T
romanvandClaude Opus 5 cd53b6a1a1 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>
2026-07-31 13:19:31 +06:00

226 lines
8.0 KiB
Swift

import AppKit
import SwiftUI
/// Editor for the things that differ between machines: where the .ovpn profile
/// lives, which server it dials, and the credentials it uses.
struct SettingsView: View {
@ObservedObject var model: AppModel
var onClose: () -> Void
@State private var draft: VPNConfig
@State private var smbUser: String
@State private var password = ""
@State private var passwordStored: Bool
@State private var message: String?
@State private var messageIsError = false
private var s: CGFloat { model.zoomScale }
init(model: AppModel, onClose: @escaping () -> Void) {
self.model = model
self.onClose = onClose
_draft = State(initialValue: model.config.vpn)
_smbUser = State(initialValue: model.config.smbUser)
_passwordStored = State(initialValue: model.vpnPasswordStored())
}
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) {
Text("Настройки")
.font(font(14, .semibold))
.padding(12 * s)
Divider()
MeasuredScrollView(maxHeight: 520 * s) {
VStack(alignment: .leading, spacing: 14 * s) {
vpnFileSection
Divider()
serverSection
Divider()
credentialsSection
Divider()
smbSection
}
.padding(12 * s)
}
Divider()
footer
}
.frame(width: 470 * s)
}
// MARK: - Profile file
private var vpnFileSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Профиль OpenVPN").font(font(12, .medium))
HStack(spacing: 8 * s) {
Text(draft.configPath)
.font(font(10))
.foregroundStyle(
FileManager.default.isReadableFile(atPath: draft.configPath)
? .secondary : Color.red)
.lineLimit(2)
.truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Button("Выбрать…") { chooseConfig() }
.buttonStyle(ScaledButtonStyle(scale: s))
}
if !draft.configPathOverride.isEmpty {
Button("Вернуть путь по умолчанию") {
draft.configPathOverride = ""
}
.buttonStyle(ScaledButtonStyle(scale: s, borderless: true))
}
labelled("Путь к openvpn", text: $draft.openvpnPath)
}
}
private func chooseConfig() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = []
panel.allowsOtherFileTypes = true
panel.message = "Выберите файл .ovpn"
panel.directoryURL = URL(fileURLWithPath: draft.baseDir)
guard panel.runModal() == .OK, let url = panel.url else { return }
draft.configPathOverride = url.path
}
// MARK: - Server
private var serverSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Сервер и сети").font(font(12, .medium))
labelled("IP сервера", text: $draft.serverHost)
Text("Пинуется на шлюз локальной сети, чтобы handshake не пошёл "
+ "в поднимаемый туннель.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
labelled("Сеть туннеля", text: $draft.tunNetwork)
labelled("Офисная сеть", text: $draft.lanNetwork)
Text("Пустая офисная сеть отключает проверку маршрута — "
+ "тогда «поднят» определяется только по процессу.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Credentials
private var credentialsSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Учётные данные VPN").font(font(12, .medium))
labelled("Запись Keychain", text: $draft.keychainService)
labelled("Логин", text: $draft.keychainAccount)
HStack(spacing: 8 * s) {
SecureField("Пароль", text: $password)
.textFieldStyle(.roundedBorder)
.font(font(11))
Button("Сохранить") { savePassword() }
.buttonStyle(ScaledButtonStyle(scale: s))
.disabled(password.isEmpty)
}
Text(passwordStored
? "Пароль в Keychain есть. Ввод заменит его."
: "Пароля в Keychain нет — подключение не заработает.")
.font(font(9))
.foregroundStyle(passwordStored ? .secondary : Color.orange)
Text("Пароль уходит прямо в связку ключей и нигде больше не "
+ "сохраняется. При подключении его читает системная утилита, "
+ "не это приложение.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func savePassword() {
// Persist the account name first: saving under a stale login would put
// the secret where connect will not look for it.
model.updateVPN(draft)
if let error = model.saveVPNPassword(password) {
message = error
messageIsError = true
} else {
message = "Пароль сохранён в Keychain"
messageIsError = false
password = ""
passwordStored = true
}
}
// MARK: - SMB
private var smbSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Учётная запись SMB").font(font(12, .medium))
labelled("Логин", text: $smbUser)
Text("Пароль для шар хранит сам macOS — его спрашивает системный "
+ "диалог при первом монтировании.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Chrome
private func labelled(_ title: String, text: Binding<String>) -> some View {
HStack(spacing: 8 * s) {
Text(title)
.font(font(11))
.frame(width: 120 * s, alignment: .leading)
TextField("", text: text)
.textFieldStyle(.roundedBorder)
.font(font(11))
}
}
private var footer: some View {
HStack(spacing: 8 * s) {
if let message {
Text(message)
.font(font(10))
.foregroundStyle(messageIsError ? Color.red : .secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button("Закрыть") { onClose() }
Button("Сохранить") {
model.updateVPN(draft)
model.updateSMBUser(smbUser)
onClose()
}
}
.buttonStyle(ScaledButtonStyle(scale: s))
.padding(12 * s)
}
}