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>
226 lines
8.0 KiB
Swift
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)
|
|
}
|
|
}
|