Files
GHOSTWIRE/ios/GHOSTWIRE/PeerDetailView.swift
T
Daniel Redetzke 3a1ca39f3f iOS: create and manage setup links like the web interface
Add peer and Issue new config offer the same choice as the web UI: show
the config now or send a one-time setup link (1 h, 24 h or 7 d, PIN on by
default). The link sheet shares the link through the iOS share sheet and
shows the PIN, expiry and a QR code of the link. The peer page gets a
setup link card with share, copy and revoke.
2026-10-03 23:29:28 +03:00

317 lines
14 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
struct PeerDetailView: View {
let peerID: String
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var peer: Peer?
@State private var server: ServerConfig?
@State private var range = "7d"
@State private var points: [StatPoint] = []
@State private var error: String?
@State private var issuing = false
@State private var issueWithLink = false
@State private var showingLink = false
@State private var confirmRevoke = false
@State private var copiedLink = false
@State private var confirmDelete = false
@State private var editing = false
@State private var sessions: [ConnSession] = []
@State private var allSessions = false
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 16) {
if let error { Notice(text: error, isError: true) }
if let p = peer {
header(p)
if let s = p.setup { setupLink(p, s) }
traffic
connection(p)
history.id("history")
clientConfig(p)
settings(p)
} else if error == nil {
ProgressView().padding(40)
}
}
.padding(16)
}
.background(Color.gwGround)
#if DEBUG
// Development: `-scrollToHistory YES` for screenshots of the history.
.task(id: sessions.count) {
if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) }
// `-showSetupLink YES` opens the pending setup link.
if UserDefaults.standard.bool(forKey: "showSetupLink"), peer?.setup != nil { showingLink = true }
}
#endif
}
.navigationTitle(peer?.name ?? "Peer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let p = peer {
Menu {
Button { Task { await toggle(p) } } label: {
Label(p.enabled ? "Disable" : "Enable", systemImage: p.enabled ? "pause.circle" : "play.circle")
}
Button(role: .destructive) { confirmDelete = true } label: { Label("Delete", systemImage: "trash") }
} label: {
Label("Actions", systemImage: "ellipsis.circle")
}
}
}
.confirmationDialog("Delete \(peer?.name ?? "peer")?", isPresented: $confirmDelete, titleVisibility: .visible) {
Button("Delete peer", role: .destructive) { Task { await delete() } }
} message: {
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.")
}
.confirmationDialog("Revoke the setup link?", isPresented: $confirmRevoke, titleVisibility: .visible) {
Button("Revoke link", role: .destructive) { Task { await revoke() } }
} message: {
Text("The link stops working immediately.")
}
.sheet(isPresented: $issuing, onDismiss: { Task { await load() } }) {
if let p = peer { IssueSheet(peer: p, startWithLink: issueWithLink) }
}
.sheet(isPresented: $showingLink) {
if let p = peer { SetupLinkSheet(peer: p) }
}
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
}
.refreshable { await load() }
.task { await load() }
}
private func header(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(p.name).font(.title2.weight(.semibold))
StatusBadge(state: PeerState(p))
Text((p.note.isEmpty ? "" : p.note + " · ") + "created " + fmtDate(p.created))
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var traffic: some View {
VStack(alignment: .leading, spacing: 10) {
SectionTitle(text: "Traffic")
Picker("Range", selection: $range) {
Text("24 h").tag("24h")
Text("7 days").tag("7d")
Text("30 days").tag("30d")
}
.pickerStyle(.segmented)
.onChange(of: range) { Task { await loadStats() } }
TrafficTotals(points: points)
if !points.isEmpty { TrafficChart(points: points, range: range, mode: .pair) }
}
.card()
}
private func connection(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 12) {
SectionTitle(text: "Connection")
KV(key: "Tunnel address", value: p.ipv4 + "/32" + (p.ipv6.map { "\n" + $0 + "/128" } ?? ""), mono: true)
KV(key: "Endpoint", value: p.stats.endpoint.isEmpty ? "–" : p.stats.endpoint, mono: true)
KV(key: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–")
KV(key: "Latest handshake", value: ago(p.stats.lastHandshake))
KV(key: "Public key", value: p.publicKey.isEmpty ? "–" : p.publicKey, mono: true)
KV(key: "Preshared key", value: p.hasPresharedKey ? "Set" : "None")
KV(key: "All-time traffic", value: "Download \(fmtBytes(p.stats.downTotal)) · Upload \(fmtBytes(p.stats.upTotal))")
}
.card()
}
private var history: some View {
VStack(alignment: .leading, spacing: 0) {
SectionTitle(text: "Connection history")
Text("Newest first. A new row starts when the device changes networks.")
.font(.caption)
.foregroundStyle(Color.gwText2)
.padding(.bottom, 8)
if sessions.isEmpty {
Text("No connections recorded yet.").font(.footnote).foregroundStyle(Color.gwText2).padding(.vertical, 6)
}
let shown = allSessions ? sessions : Array(sessions.prefix(8))
ForEach(shown) { se in
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(se.start.formatted(.dateTime.day().month(.abbreviated).hour().minute()))
.font(.subheadline.weight(.medium))
if se.open {
HStack(spacing: 4) {
Circle().fill(Color.gwGood).frame(width: 6, height: 6)
Text("online")
}
.font(.caption.weight(.medium))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.gwBadge, in: Capsule())
}
}
Text(se.geo?.label.isEmpty == false ? se.geo!.label : "Unknown location")
.font(.footnote)
.foregroundStyle(se.geo == nil ? Color.gwText2 : Color.gwText)
Text(se.ip + " · " + fmtDuration(se.seconds))
.font(.mono(.caption))
.foregroundStyle(Color.gwText2)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 4) {
Text("↓ " + fmtBytes(se.down)).font(.footnote.monospacedDigit())
Text("↑ " + fmtBytes(se.up)).font(.footnote.monospacedDigit()).foregroundStyle(Color.gwText2)
}
}
.padding(.vertical, 8)
.accessibilityElement(children: .combine)
if se.id != shown.last?.id { Divider() }
}
if sessions.count > 8 {
Button(allSessions ? "Show fewer" : "Show all \(sessions.count)") { allSessions.toggle() }
.font(.footnote.weight(.medium))
.padding(.top, 8)
}
HStack(spacing: 4) {
Text("Country and network:")
Link("IP Geolocation by DB-IP", destination: URL(string: "https://db-ip.com")!).underline()
}
.font(.caption2)
.foregroundStyle(Color.gwText2)
.padding(.top, 10)
}
.card()
}
private func clientConfig(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 12) {
SectionTitle(text: "Client configuration")
Text("This server doesn't keep the peer's private key. To set up a device again, issue a new config. The old one stops working.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
Button { issueWithLink = false; issuing = true } label: {
Label(p.publicKey.isEmpty ? "Issue config…" : "Issue new config…", systemImage: "qrcode")
}
.buttonStyle(PrimaryButtonStyle())
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.")
.font(.caption)
.foregroundStyle(Color.gwText2)
}
.card()
}
private func settings(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionTitle(text: "Settings")
Spacer()
Button("Edit") { editing = true }.disabled(server == nil)
}
KV(key: "AllowedIPs (client)", value: p.effectiveAllowedIPs.joined(separator: ", ") + (p.allowedIPs == nil ? " · server default" : ""), mono: true)
KV(key: "DNS", value: (p.effectiveDNS.isEmpty ? "none" : p.effectiveDNS.joined(separator: ", ")) + (p.dns == nil ? " · server default" : ""), mono: true)
KV(key: "Persistent keepalive", value: (p.effectiveKeepalive > 0 ? "\(p.effectiveKeepalive) s" : "off") + (p.keepalive == nil ? " · server default" : ""))
}
.card()
}
private func load() async {
guard let api = session.api else { return }
do {
async let p: Peer = api.get("/peers/\(peerID)")
async let s: ServerConfig = api.get("/server")
(peer, server) = try await (p, s)
error = nil
await loadStats()
if let r: SessionsResponse = try? await api.get("/peers/\(peerID)/sessions?limit=100") { sessions = r.sessions }
} catch {
self.error = session.message(for: error)
}
}
private func loadStats() async {
guard let api = session.api else { return }
if let r: StatsResponse = try? await api.get("/peers/\(peerID)/stats?range=\(range)") { points = r.points }
}
private func toggle(_ p: Peer) async {
guard let api = session.api else { return }
do {
let r: PeerResult = try await api.send("POST", "/peers/\(p.id)/" + (p.enabled ? "disable" : "enable"))
session.reportApply(r.applyError)
peer = r.peer
} catch {
session.alert = session.message(for: error)
}
}
private func delete() async {
guard let api = session.api else { return }
do {
let r: ApplyResult = try await api.send("DELETE", "/peers/\(peerID)")
session.reportApply(r.applyError)
dismiss()
} catch {
session.alert = session.message(for: error)
}
}
private func setupLink(_ p: Peer, _ s: SetupInfo) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionTitle(text: "Setup link")
Spacer()
Text(s.expired ? "Expired" : "Not opened yet").font(.footnote).foregroundStyle(Color.gwText2)
}
KV(key: s.expired ? "Expired" : "Expires", value: fmtStamp(s.expires))
KV(key: "PIN", value: s.pinRequired ? "Required · \(s.pinFails) of 5 wrong tries" : "Not required")
if !p.publicKey.isEmpty {
KV(key: "Current config", value: "Keeps working until the link is opened")
}
if s.expired {
Button("New link…") { issueWithLink = true; issuing = true }
.buttonStyle(PrimaryButtonStyle())
Button("Remove") { Task { await revoke() } }
.buttonStyle(SecondaryButtonStyle())
} else {
Button { showingLink = true } label: {
Label(s.pinRequired ? "Share link & PIN" : "Share link", systemImage: "square.and.arrow.up")
}
.buttonStyle(PrimaryButtonStyle())
HStack(spacing: 12) {
Button { Task { await copyLink() } } label: {
Label(copiedLink ? "Copied" : "Copy link", systemImage: copiedLink ? "checkmark" : "doc.on.doc")
}
.buttonStyle(SecondaryButtonStyle())
Button("Revoke", role: .destructive) { confirmRevoke = true }
.buttonStyle(SecondaryButtonStyle(ink: .gwErrInk))
}
}
}
.card()
}
private func copyLink() async {
guard let api = session.api else { return }
do {
let s: SetupSecret = try await api.get("/peers/\(peerID)/setup")
UIPasteboard.general.string = s.url
copiedLink = true
} catch {
session.alert = session.message(for: error)
}
}
private func revoke() async {
guard let api = session.api else { return }
do {
let r: PeerOnly = try await api.send("DELETE", "/peers/\(peerID)/setup")
peer = r.peer
} catch {
session.alert = session.message(for: error)
}
}
}