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) -> 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) } }