Network panel: several Amnezia connections (expand the Amnezia row, pick a connection or Auto, add a vpn:// key with live check, remove), auto = fastest across all Amnezia and VLESS exits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,10 @@ final class NetCore: ObservableObject {
|
|||||||
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
|
/// TUN on = the core carries the Mac's traffic; off = idle (e.g. back on AmneziaVPN).
|
||||||
@Published var tunOn = false
|
@Published var tunOn = false
|
||||||
@Published var lastError: String?
|
@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).
|
/// Delays measured by the group test (covers the subscription nodes too).
|
||||||
private var measured: [String: Int] = [:]
|
private var measured: [String: Int] = [:]
|
||||||
@@ -86,13 +90,17 @@ final class NetCore: ObservableObject {
|
|||||||
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
|
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
|
||||||
tunOn = tun["enable"] as? Bool ?? false
|
tunOn = tun["enable"] as? Bool ?? false
|
||||||
}
|
}
|
||||||
// Subscription (VLESS) nodes keep their history in the provider, not in /proxies.
|
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
|
||||||
|
// provider, not in /proxies.
|
||||||
var providerDelay: [String: Int] = [:]
|
var providerDelay: [String: Int] = [:]
|
||||||
if let p = await request("/providers/proxies/vless-cluster") as? [String: Any],
|
var awgNames: [String] = []
|
||||||
let list = p["proxies"] as? [[String: Any]] {
|
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 {
|
for x in list {
|
||||||
if let n = x["name"] as? String, let h = x["history"] as? [[String: Any]],
|
guard let n = x["name"] as? String else { continue }
|
||||||
let d = h.last?["delay"] as? Int { providerDelay[n] = d }
|
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? {
|
func lastDelay(_ name: String) -> Int? {
|
||||||
@@ -100,14 +108,26 @@ final class NetCore: ObservableObject {
|
|||||||
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
|
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
|
||||||
return providerDelay[name]
|
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] {
|
if let g = proxies["ai-out"], let members = g["all"] as? [String] {
|
||||||
currentExit = g["now"] as? String ?? ""
|
currentExit = g["now"] as? String ?? ""
|
||||||
autoPick = proxies["auto"]?["now"] as? String ?? ""
|
autoPick = proxies["auto"]?["now"] as? String ?? ""
|
||||||
exits = members.map { name in
|
exits = members.map { name in
|
||||||
let (t, sub) = Self.pretty(name)
|
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
|
return NetExit(id: name, title: t, subtitle: name == "auto" && !autoPick.isEmpty
|
||||||
? "сейчас: \(Self.pretty(autoPick).0)" : sub,
|
? "сейчас: \(Self.pretty(autoPick).0)"
|
||||||
delay: name == "auto" ? lastDelay(autoPick) : lastDelay(name))
|
: (name == "amnezia" && !amneziaEffective.isEmpty
|
||||||
|
? "\(awgNames.count) подкл. · сейчас \(Self.pretty(amneziaEffective).0)" : sub),
|
||||||
|
delay: delay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clients = Self.clientInfo.keys.sorted().compactMap { id in
|
clients = Self.clientInfo.keys.sorted().compactMap { id in
|
||||||
@@ -127,6 +147,10 @@ final class NetCore: ObservableObject {
|
|||||||
// Members that didn't answer are missing from the map: mark them dead.
|
// 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 }
|
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) }
|
for c in clients { _ = await request("/proxies/\(c.id)/delay?\(test)", timeout: 8) }
|
||||||
await refresh()
|
await refresh()
|
||||||
busy = false
|
busy = false
|
||||||
@@ -147,12 +171,99 @@ final class NetCore: ObservableObject {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
static var amneziaRunning: Bool {
|
func select(amnezia name: String) async {
|
||||||
NSWorkspace.shared.runningApplications.contains {
|
amneziaNow = name
|
||||||
($0.bundleIdentifier ?? "").lowercased().contains("amnezia") || ($0.localizedName ?? "").contains("Amnezia")
|
_ = 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).
|
/// Turns traffic capture on/off without a password (the core keeps running).
|
||||||
func setTun(_ on: Bool) async {
|
func setTun(_ on: Bool) async {
|
||||||
if on && Self.amneziaRunning {
|
if on && Self.amneziaRunning {
|
||||||
@@ -169,8 +280,10 @@ final class NetCore: ObservableObject {
|
|||||||
|
|
||||||
static func pretty(_ name: String) -> (String, String) {
|
static func pretty(_ name: String) -> (String, String) {
|
||||||
switch name {
|
switch name {
|
||||||
case "auto": return ("Авто", "первый живой")
|
case "auto": return ("Авто", "самый быстрый выход")
|
||||||
case "amnezia": return ("Амнезия", "AmneziaWG · свой сервер")
|
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("node3"): return ("node3", "VLESS · Нидерланды")
|
||||||
case let n where n.contains("node2"): return ("node2", "VLESS · Париж")
|
case let n where n.contains("node2"): return ("node2", "VLESS · Париж")
|
||||||
case "Stockholm": return ("Stockholm", "VLESS · Стокгольм")
|
case "Stockholm": return ("Stockholm", "VLESS · Стокгольм")
|
||||||
@@ -264,10 +377,14 @@ struct NetPanel: View {
|
|||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
|
Text("ВЫХОД В ИНТЕРНЕТ").font(.system(size: 9.5, weight: .bold)).foregroundColor(Color(hex: "#6B7079"))
|
||||||
ForEach(net.exits) { e in
|
ForEach(net.exits) { e in
|
||||||
NetRow(title: e.title, subtitle: e.subtitle, delay: e.delay,
|
if e.id == "amnezia" {
|
||||||
isOn: net.currentExit == e.id, accent: "#D97757") {
|
AmneziaSection(net: net, exit: e)
|
||||||
guard net.currentExit != e.id else { return }
|
} else {
|
||||||
Task { await net.select(exit: e.id) }
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,3 +462,138 @@ struct NetRow: View {
|
|||||||
.onHover { hovered = $0 }
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user