Files
brov-macbook/NotchBuddy/Sources/App/NetPanel.swift
T

600 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
// MARK: - Networks (globe in the header)
//
// BroV is the remote for the network core (mihomo). It talks to the core's local REST
// API (address + secret in ~/Library/Application Support/NotchBuddy/netcore.json):
// • left column — the internet exit: group "ai-out" (Авто / Амнезия / each VLESS node)
// • right column — client networks: groups "<client>-sw" switched between REJECT and
// the client's WireGuard tunnel.
// Switching a group never reloads the core, so open connections (this chat) survive.
struct NetExit: Identifiable, Equatable {
var id: String // proxy name in the core
var title: String
var subtitle: String
var delay: Int? // ms, nil = unknown, 0 = dead
}
struct NetClient: Identifiable, Equatable {
var id: String // "saga"
var title: String
var subnet: String
var on: Bool
var delay: Int?
}
@MainActor
final class NetCore: ObservableObject {
static let shared = NetCore()
@Published var running = false
@Published var exits: [NetExit] = []
@Published var currentExit = ""
@Published var autoPick = "" // what "auto" chose
@Published var clients: [NetClient] = []
@Published var busy = false
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
@Published var tunOn = false
@Published var lastError: String?
/// Amnezia connections (provider "amnezia-keys"), the group's choice and auto's pick.
@Published var amneziaConns: [NetExit] = []
@Published var amneziaNow = ""
@Published var amneziaAutoPick = ""
/// Delays measured by the group test (covers the subscription nodes too).
private var measured: [String: Int] = [:]
private var base = ""
private var secret = ""
static let clientInfo: [String: (title: String, subnet: String)] = [
"saga": ("Сага", "192.168.8.0/24"),
"planet9": ("Planet9", "192.168.68.0/24"),
]
private func loadEndpoint() -> Bool {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore.json")
guard let data = try? Data(contentsOf: url),
let j = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let c = j["controller"], let s = j["secret"] else { return false }
base = c; secret = s
return true
}
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil,
timeout: TimeInterval = 4) async -> Any? {
guard !base.isEmpty || loadEndpoint(),
let url = URL(string: base + path) else { return nil }
var r = URLRequest(url: url, timeoutInterval: timeout)
r.httpMethod = method
r.setValue("Bearer \(secret)", forHTTPHeaderField: "Authorization")
if let body {
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
guard let (data, resp) = try? await URLSession.shared.data(for: r),
let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil }
return data.isEmpty ? [:] : (try? JSONSerialization.jsonObject(with: data)) ?? [:]
}
/// Reads groups and last known delays.
func refresh() async {
guard let all = await request("/proxies") as? [String: Any],
let proxies = all["proxies"] as? [String: [String: Any]] else {
running = false
return
}
running = true
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
tunOn = tun["enable"] as? Bool ?? false
}
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
// provider, not in /proxies.
var providerDelay: [String: Int] = [:]
var awgNames: [String] = []
for prov in ["vless-cluster", "amnezia-keys"] {
guard let p = await request("/providers/proxies/\(prov)") as? [String: Any],
let list = p["proxies"] as? [[String: Any]] else { continue }
for x in list {
guard let n = x["name"] as? String else { continue }
if prov == "amnezia-keys" { awgNames.append(n) }
if let h = x["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { providerDelay[n] = d }
}
}
func lastDelay(_ name: String) -> Int? {
if let d = measured[name] { return d }
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
return providerDelay[name]
}
amneziaNow = proxies["amnezia"]?["now"] as? String ?? ""
amneziaAutoPick = proxies["amnezia-auto"]?["now"] as? String ?? ""
let amneziaEffective = amneziaNow == "amnezia-auto" ? amneziaAutoPick : amneziaNow
amneziaConns = [NetExit(id: "amnezia-auto", title: "Авто",
subtitle: amneziaAutoPick.isEmpty ? "быстрейшее подключение"
: "сейчас: \(Self.pretty(amneziaAutoPick).0)",
delay: lastDelay(amneziaAutoPick))]
+ awgNames.map { NetExit(id: $0, title: Self.pretty($0).0, subtitle: "AmneziaWG", delay: lastDelay($0)) }
if let g = proxies["ai-out"], let members = g["all"] as? [String] {
currentExit = g["now"] as? String ?? ""
autoPick = proxies["auto"]?["now"] as? String ?? ""
exits = members.map { name in
let (t, sub) = Self.pretty(name)
let delay: Int? = name == "auto" ? lastDelay(autoPick)
: (name == "amnezia" ? lastDelay(amneziaEffective) : lastDelay(name))
return NetExit(id: name, title: t, subtitle: name == "auto" && !autoPick.isEmpty
? "сейчас: \(Self.pretty(autoPick).0)"
: (name == "amnezia" && !amneziaEffective.isEmpty
? "\(awgNames.count) подкл. · сейчас \(Self.pretty(amneziaEffective).0)" : sub),
delay: delay)
}
}
clients = Self.clientInfo.keys.sorted().compactMap { id in
guard let g = proxies["\(id)-sw"] else { return nil }
let info = Self.clientInfo[id]!
return NetClient(id: id, title: info.title, subnet: info.subnet,
on: (g["now"] as? String) == id, delay: lastDelay(id))
}
}
/// Measures every exit and client tunnel (in parallel, inside the core).
func measure() async {
busy = true
let test = "url=https://www.gstatic.com/generate_204&timeout=5000"
if let m = await request("/group/ai-out/delay?\(test)", timeout: 8) as? [String: Int] {
measured = m
// Members that didn't answer are missing from the map: mark them dead.
for e in exits where e.id != "auto" && m[e.id] == nil { measured[e.id] = 0 }
}
if let m = await request("/group/amnezia/delay?\(test)", timeout: 8) as? [String: Int] {
for (k, v) in m { measured[k] = v }
for c in amneziaConns where c.id != "amnezia-auto" && m[c.id] == nil { measured[c.id] = 0 }
}
for c in clients { _ = await request("/proxies/\(c.id)/delay?\(test)", timeout: 8) }
await refresh()
busy = false
}
func select(exit name: String) async {
currentExit = name
_ = await request("/proxies/ai-out", method: "PUT", body: ["name": name])
SoundEngine.shared.play("blip")
await refresh()
}
func set(client id: String, on: Bool) async {
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on }
_ = await request("/proxies/\(id)-sw", method: "PUT", body: ["name": on ? id : "REJECT"])
SoundEngine.shared.play(on ? "pop" : "close")
if on { _ = await request("/proxies/\(id)/delay?url=https://www.gstatic.com/generate_204&timeout=5000", timeout: 8) }
await refresh()
}
func select(amnezia name: String) async {
amneziaNow = name
_ = await request("/proxies/amnezia", method: "PUT", body: ["name": name])
if currentExit != "amnezia" { _ = await request("/proxies/ai-out", method: "PUT", body: ["name": "amnezia"]) }
SoundEngine.shared.play("blip")
await refresh()
}
static var coreDir: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore")
}
/// Runs gen.py (the single converter for keys → core config) in the core folder.
private nonisolated static func gen(_ args: [String]) async -> String {
await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
p.arguments = ["gen.py"] + args
p.currentDirectoryURL = coreDir
let out = Pipe()
p.standardOutput = out
p.standardError = out
guard (try? p.run()) != nil else { cont.resume(returning: ""); return }
let data = out.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
cont.resume(returning: String(data: data, encoding: .utf8) ?? "")
}
}
}
/// Checks a pasted vpn:// key, stores it, rebuilds the Amnezia provider and tests it.
/// Returns a message for the add panel.
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
let keys = Self.coreDir.appendingPathComponent("keys")
let pending = keys.appendingPathComponent(".pending.vpnkey")
do {
try key.write(to: pending, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pending.path)
} catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
let out = await Self.gen(["--check", pending.path])
guard let line = out.split(separator: "\n").last,
let j = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
j["ok"] as? Bool == true else {
try? FileManager.default.removeItem(at: pending)
let err = ((try? JSONSerialization.jsonObject(with: Data((out.split(separator: "\n").last ?? "").utf8))) as? [String: Any])?["error"] as? String
return (false, "Ключ не подходит: \(err ?? "не удалось разобрать")")
}
let name = (j["name"] as? String) ?? "amnezia"
let server = "\(j["server"] as? String ?? "?"):\(j["port"] as? Int ?? 0)"
// File name from the connection name; never overwrite an existing one.
var slug = name.lowercased().map { $0.isLetter || $0.isNumber ? String($0) : "-" }.joined()
if slug.isEmpty { slug = "amnezia" }
var dest = keys.appendingPathComponent("\(slug).vpnkey")
var n = 2
while FileManager.default.fileExists(atPath: dest.path) {
dest = keys.appendingPathComponent("\(slug)-\(n).vpnkey"); n += 1
}
do { try FileManager.default.moveItem(at: pending, to: dest) }
catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
_ = await Self.gen([])
// Live: re-read only the Amnezia provider, the core keeps running.
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
let proxy = "AWG " + dest.deletingPathExtension().lastPathComponent
let enc = proxy.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? proxy
let hc = await request("/providers/proxies/amnezia-keys/\(enc)/healthcheck?url=https://www.gstatic.com/generate_204&timeout=6000",
timeout: 10) as? [String: Any]
await refresh()
if let d = hc?["delay"] as? Int, d > 0 {
SoundEngine.shared.play("finish")
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
}
return (true, "Ключ «\(name)» сохранён (\(server)), но сервер пока не отвечает. Он будет участвовать в выборе, когда оживёт.")
}
func removeAmnezia(_ proxyName: String) async {
let file = String(proxyName.dropFirst(4)) + ".vpnkey"
try? FileManager.default.removeItem(at: Self.coreDir.appendingPathComponent("keys").appendingPathComponent(file))
if amneziaNow == proxyName { _ = await request("/proxies/amnezia", method: "PUT", body: ["name": "amnezia-auto"]) }
_ = await Self.gen([])
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
SoundEngine.shared.play("close")
await refresh()
}
static var amneziaRunning: Bool {
// The app holds routes; its background AmneziaVPN-service doesn't.
NSWorkspace.shared.runningApplications.contains { $0.localizedName == "AmneziaVPN" }
}
/// Turns traffic capture on/off without a password (the core keeps running).
func setTun(_ on: Bool) async {
if on && Self.amneziaRunning {
lastError = "Сначала выключи AmneziaVPN — иначе она и ядро подерутся за маршрут."
return
}
lastError = nil
tunOn = on
_ = await request("/configs", method: "PATCH", body: ["tun": ["enable": on]], timeout: 8)
SoundEngine.shared.play(on ? "pop" : "close")
try? await Task.sleep(for: .seconds(1))
await refresh()
}
static func pretty(_ name: String) -> (String, String) {
switch name {
case "auto": return ("Авто", "самый быстрый выход")
case "amnezia": return ("Амнезия", "AmneziaWG")
case "amnezia-auto": return ("Авто", "быстрейшее подключение")
case let n where n.hasPrefix("AWG "): return (String(n.dropFirst(4)), "AmneziaWG")
case let n where n.contains("node3"): return ("node3", "VLESS · Нидерланды")
case let n where n.contains("node2"): return ("node2", "VLESS · Париж")
case "Stockholm": return ("Stockholm", "VLESS · Стокгольм")
case "SkandiFlora": return ("SkandiFlora", "VLESS · Стокгольм")
case "Helsinki": return ("Helsinki", "VLESS · Хельсинки")
default: return (name, "VLESS")
}
}
}
// MARK: - Header button
struct NetGlobeButton: View {
@ObservedObject private var net = NetCore.shared
@State private var open = false
var body: some View {
Button { open.toggle() } label: {
Image(systemName: "globe")
.font(.system(size: 14))
.foregroundColor(open ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C"))
.overlay(alignment: .topTrailing) {
Circle()
.fill(net.running ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
.offset(x: 2, y: -1)
}
}
.buttonStyle(.plain)
.help("Сети")
.task { await net.refresh() }
.popover(isPresented: $open, arrowEdge: .bottom) {
NetPanel(net: net)
.notchPopoverStyle()
}
}
}
// MARK: - Panel
struct NetPanel: View {
@ObservedObject var net: NetCore
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "globe").font(.system(size: 13, weight: .semibold))
Text("Сети").font(.system(size: 14, weight: .semibold))
Circle().fill(net.running ? Color(hex: "#34D399") : Color(hex: "#F4505E")).frame(width: 6, height: 6)
Text(net.running ? "ядро работает" : "ядро не запущено")
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
Spacer()
if net.running {
Button {
Task { await net.measure() }
} label: {
HStack(spacing: 4) {
if net.busy { ProgressView().controlSize(.mini) }
else { Image(systemName: "speedometer").font(.system(size: 11)) }
Text("Замерить").font(.system(size: 11, weight: .medium))
}
.padding(.horizontal, 8).frame(height: 22)
.background(Capsule().fill(Color.white.opacity(0.08)))
}
.buttonStyle(.plain)
.disabled(net.busy)
}
}
if let err = net.lastError {
Text(err).font(.system(size: 11.5)).foregroundColor(Color(hex: "#FB923C"))
.fixedSize(horizontal: false, vertical: true).frame(width: 444, alignment: .leading)
}
if !net.running {
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/brov-secrets/install-netd.sh\nДальше ядро будет запускаться само при включении Мака.")
.font(.system(size: 11.5))
.foregroundColor(Color(hex: "#B0B5BE"))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 444, alignment: .leading)
} else {
NetRow(title: "Ядро перехватывает трафик",
subtitle: net.tunOn ? "весь трафик Мака идёт через BroV" : "выключено — Мак ходит сам (можно включить AmneziaVPN)",
delay: nil, isOn: net.tunOn, accent: "#A78BFA") {
Task { await net.setTun(!net.tunOn) }
}
.frame(width: 444)
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 1)
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
ForEach(net.exits) { e in
if e.id == "amnezia" {
AmneziaSection(net: net, exit: e)
} else {
NetRow(title: e.title, subtitle: e.subtitle, delay: e.delay,
isOn: net.currentExit == e.id, accent: "#D97757") {
guard net.currentExit != e.id else { return }
Task { await net.select(exit: e.id) }
}
}
}
}
.frame(width: 230)
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1)
VStack(alignment: .leading, spacing: 4) {
Text("СЕТИ КЛИЕНТОВ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
ForEach(net.clients) { c in
NetRow(title: c.title, subtitle: c.subnet, delay: c.on ? c.delay : nil,
isOn: c.on, accent: "#34D399") {
Task { await net.set(client: c.id, on: !c.on) }
}
}
Text("Выключенная сеть недоступна. Claude и интернет это не трогает.")
.font(.system(size: 10.5)).foregroundColor(Color(hex: "#6B7079"))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
.frame(width: 200)
}
}
}
.padding(14)
.task {
await net.refresh()
if net.running { await net.measure() }
}
}
}
struct NetRow: View {
let title: String
let subtitle: String
let delay: Int?
let isOn: Bool
let accent: String
let action: () -> Void
@State private var hovered = false
private var delayText: String {
guard let d = delay else { return "" }
return d == 0 ? "нет ответа" : "\(d) мс"
}
private var delayColor: String {
guard let d = delay else { return "#6B7079" }
if d == 0 { return "#F4505E" }
return d < 120 ? "#34D399" : (d < 300 ? "#F5A524" : "#F4505E")
}
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.system(size: 12.5, weight: .semibold))
Text(subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
}
Spacer(minLength: 4)
Text(delayText).font(.system(size: 10.5, weight: .medium)).monospacedDigit()
.foregroundColor(Color(hex: delayColor))
// Switch look-alike
ZStack(alignment: isOn ? .trailing : .leading) {
Capsule().fill(isOn ? Color(hex: accent) : Color.white.opacity(0.14))
.frame(width: 30, height: 18)
Circle().fill(Color.white).frame(width: 14, height: 14).padding(2)
}
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: isOn)
}
.padding(.horizontal, 8).padding(.vertical, 5)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(hovered ? 0.06 : 0)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
}
}
// MARK: - Amnezia: several connections
/// "Амнезия" row: the switch picks Amnezia as the exit, the name opens the list of
/// connections (Авто / each key) with "+ Добавить подключение" at the end.
struct AmneziaSection: View {
@ObservedObject var net: NetCore
let exit: NetExit
@State private var expanded = false
@State private var adding = false
@State private var confirmRemove: String?
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Button { withAnimation(.easeOut(duration: 0.15)) { expanded.toggle() } } label: {
HStack(spacing: 6) {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(exit.title).font(.system(size: 12.5, weight: .semibold))
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.system(size: 8.5, weight: .bold)).foregroundColor(Color(hex: "#8E939C"))
}
Text(exit.subtitle).font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")).lineLimit(1)
}
Spacer(minLength: 4)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
NetRow(title: "", subtitle: "", delay: exit.delay, isOn: net.currentExit == "amnezia", accent: "#D97757") {
guard net.currentExit != "amnezia" else { return }
Task { await net.select(exit: "amnezia") }
}
.frame(width: 110)
}
.padding(.leading, 8)
if expanded {
VStack(alignment: .leading, spacing: 1) {
ForEach(net.amneziaConns) { c in
NetRow(title: c.title, subtitle: c.subtitle, delay: c.delay,
isOn: net.currentExit == "amnezia" && net.amneziaNow == c.id, accent: "#D97757") {
Task { await net.select(amnezia: c.id) }
}
.contextMenu {
if c.id != "amnezia-auto" {
Button("Удалить подключение…", role: .destructive) { confirmRemove = c.id }
}
}
}
Button { adding = true } label: {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill").font(.system(size: 12))
Text("Добавить подключение").font(.system(size: 12, weight: .medium))
}
.foregroundColor(Color(hex: "#D97757"))
.padding(.horizontal, 8).padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $adding, arrowEdge: .trailing) {
AddAmneziaKeyView(net: net) { adding = false }
.notchPopoverStyle()
}
}
.padding(.leading, 12)
.overlay(alignment: .leading) {
Rectangle().fill(Color.white.opacity(0.08)).frame(width: 1).padding(.leading, 6)
}
}
}
.confirmationDialog("Удалить подключение «\(NetCore.pretty(confirmRemove ?? "").0)»?",
isPresented: Binding(get: { confirmRemove != nil }, set: { if !$0 { confirmRemove = nil } })) {
Button("Удалить", role: .destructive) {
if let n = confirmRemove { Task { await net.removeAmnezia(n) } }
confirmRemove = nil
}
Button("Отмена", role: .cancel) { confirmRemove = nil }
} message: {
Text("Ключ будет удалён с этого Мака. Вернуть можно, вставив его снова.")
}
}
}
/// Paste a vpn:// key → BroV checks it, stores it and tells whether it connected.
struct AddAmneziaKeyView: View {
@ObservedObject var net: NetCore
let onDone: () -> Void
@State private var text = ""
@State private var working = false
@State private var result: (ok: Bool, message: String)?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Новое подключение Амнезии").font(.system(size: 13, weight: .semibold))
Text("Вставь ключ vpn:// из приложения Amnezia (Поделиться → AmneziaWG или ключ целиком).")
.font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
.fixedSize(horizontal: false, vertical: true)
TextEditor(text: $text)
.font(.system(size: 10.5, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(6)
.frame(height: 90)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.06)))
if let r = result {
Text(r.message)
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(Color(hex: r.ok ? "#34D399" : "#FB923C"))
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button(result?.ok == true ? "Готово" : "Отмена") { onDone() }
Button {
working = true
Task {
result = await net.addAmneziaKey(text)
if result?.ok == true { text = "" }
working = false
}
} label: {
HStack(spacing: 5) {
if working { ProgressView().controlSize(.small) }
Text("Проверить и добавить")
}
}
.keyboardShortcut(.defaultAction)
.disabled(working || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(14)
.frame(width: 340)
}
}