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.
This commit is contained in:
@@ -97,4 +97,12 @@ final class API {
|
||||
func send<T: Decodable>(_ method: String, _ path: String, _ body: [String: Any?]? = nil) async throws -> T {
|
||||
try Self.decoder.decode(T.self, from: try await data(method, path, body: body))
|
||||
}
|
||||
|
||||
/// Creates a peer or issues a config. The server answers with the config,
|
||||
/// or with a setup link when the body asked for one.
|
||||
func issue(_ path: String, _ body: [String: Any?]?) async throws -> IssueOutcome {
|
||||
let d = try await data("POST", path, body: body)
|
||||
if let l = try? Self.decoder.decode(LinkCreated.self, from: d) { return .link(l) }
|
||||
return .config(try Self.decoder.decode(IssuedConfig.self, from: d))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ func fmtDate(_ date: Date?) -> String {
|
||||
return date.formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
|
||||
func fmtStamp(_ date: Date) -> String {
|
||||
date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
|
||||
}
|
||||
|
||||
/// "35 min", "2 h 5 min", "3 days".
|
||||
func fmtDuration(_ seconds: Int64) -> String {
|
||||
if seconds < 60 { return "under 1 min" }
|
||||
|
||||
@@ -30,9 +30,7 @@ struct IssuedConfigContent: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
Notice(text: issued.includesPrivateKey
|
||||
? "This is the only time the private key is shown. Scan or share it now: it is not stored on the server."
|
||||
: "The device keeps its own private key. Put it into the PrivateKey line.")
|
||||
Notice(text: "This is the only time the private key is shown. Scan or share it now: it is not stored on the server.")
|
||||
if let img = qrImage {
|
||||
Image(uiImage: img)
|
||||
.interpolation(.none)
|
||||
@@ -73,19 +71,3 @@ struct IssuedConfigContent: View {
|
||||
.background(Color.gwGround)
|
||||
}
|
||||
}
|
||||
|
||||
/// IssuedConfigContent in its own sheet, for re-issued configs.
|
||||
struct IssuedConfigView: View {
|
||||
let issued: IssuedConfig
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
IssuedConfigContent(issued: issued)
|
||||
.navigationTitle("Config for \(issued.peer.name)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,53 @@ nonisolated struct IssuedConfig: Decodable, Identifiable {
|
||||
var id: String { peer.id + peer.publicKey }
|
||||
}
|
||||
|
||||
/// A setup link for the admin to send. It sets up a device once.
|
||||
nonisolated struct SetupSecret: Decodable, Hashable {
|
||||
let url: String
|
||||
let path: String
|
||||
let pin: String?
|
||||
let expires: Date
|
||||
let qr: String
|
||||
}
|
||||
|
||||
/// The answer to creating a peer or issuing a config with a setup link.
|
||||
nonisolated struct LinkCreated: Decodable {
|
||||
let peer: Peer
|
||||
let setup: SetupSecret
|
||||
let applyError: String
|
||||
}
|
||||
|
||||
/// A config shown now, or a setup link to send.
|
||||
enum IssueOutcome: Identifiable {
|
||||
case config(IssuedConfig)
|
||||
case link(LinkCreated)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .config(let c): c.id
|
||||
case .link(let l): l.setup.path
|
||||
}
|
||||
}
|
||||
|
||||
var peer: Peer {
|
||||
switch self {
|
||||
case .config(let c): c.peer
|
||||
case .link(let l): l.peer
|
||||
}
|
||||
}
|
||||
|
||||
var applyError: String {
|
||||
switch self {
|
||||
case .config(let c): c.applyError
|
||||
case .link(let l): l.applyError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct PeerOnly: Decodable {
|
||||
let peer: Peer
|
||||
}
|
||||
|
||||
nonisolated struct ClientDefaults: Codable, Equatable {
|
||||
var dns: [String]
|
||||
var allowedIPs: [String]
|
||||
|
||||
@@ -9,8 +9,11 @@ struct PeerDetailView: View {
|
||||
@State private var range = "7d"
|
||||
@State private var points: [StatPoint] = []
|
||||
@State private var error: String?
|
||||
@State private var issued: IssuedConfig?
|
||||
@State private var confirmIssue = false
|
||||
@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] = []
|
||||
@@ -23,6 +26,7 @@ struct PeerDetailView: View {
|
||||
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")
|
||||
@@ -39,6 +43,8 @@ struct PeerDetailView: View {
|
||||
// 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
|
||||
}
|
||||
@@ -61,12 +67,17 @@ struct PeerDetailView: View {
|
||||
} message: {
|
||||
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.")
|
||||
}
|
||||
.confirmationDialog("Issue a new config?", isPresented: $confirmIssue, titleVisibility: .visible) {
|
||||
Button("Issue new config") { Task { await issue() } }
|
||||
.confirmationDialog("Revoke the setup link?", isPresented: $confirmRevoke, titleVisibility: .visible) {
|
||||
Button("Revoke link", role: .destructive) { Task { await revoke() } }
|
||||
} message: {
|
||||
Text("New keys are created. The device that uses the current config stops working until it gets the new one.")
|
||||
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(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) }
|
||||
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
|
||||
if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
|
||||
}
|
||||
@@ -181,14 +192,10 @@ struct PeerDetailView: View {
|
||||
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 { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") }
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
if let s = p.setup {
|
||||
Text(s.expired ? "The setup link expired \(fmtDate(s.expires)). Manage setup links in the web interface."
|
||||
: "A setup link is waiting to be opened (until \(fmtDate(s.expires))). Issuing a config here replaces it.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color.gwWarnInk)
|
||||
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)
|
||||
@@ -251,11 +258,57 @@ struct PeerDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func issue() async {
|
||||
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 body: [String: Any?]? = nil
|
||||
issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -110,22 +110,23 @@ struct AddPeerView: View {
|
||||
@State private var ipv4 = ""
|
||||
@State private var overrides = PeerOverrides()
|
||||
@State private var psk = true
|
||||
@State private var handover = Handover()
|
||||
@State private var error: String?
|
||||
@State private var busy = false
|
||||
@State private var issued: IssuedConfig?
|
||||
@State private var issued: IssueOutcome?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let issued {
|
||||
IssuedConfigContent(issued: issued)
|
||||
IssueOutcomeContent(outcome: issued)
|
||||
} else if let server {
|
||||
form(server)
|
||||
} else {
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
|
||||
}
|
||||
}
|
||||
.navigationTitle(issued == nil ? "Add peer" : "Config for \(issued?.peer.name ?? "")")
|
||||
.navigationTitle(issued.map(outcomeTitle) ?? "Add peer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if issued == nil {
|
||||
@@ -166,9 +167,11 @@ struct AddPeerView: View {
|
||||
} header: {
|
||||
Text("Keys")
|
||||
} footer: {
|
||||
Text("The private key appears once in the config and QR code. It isn't stored.")
|
||||
Text("The private key is never stored on the server.")
|
||||
}
|
||||
|
||||
HandoverSection(h: $handover)
|
||||
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(Color.gwErrInk) }
|
||||
}
|
||||
@@ -187,7 +190,8 @@ struct AddPeerView: View {
|
||||
body["note"] = note.trimmingCharacters(in: .whitespaces)
|
||||
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
|
||||
body["presharedKey"] = psk
|
||||
let r: IssuedConfig = try await api.send("POST", "/peers", body)
|
||||
body.merge(handover.body) { _, new in new }
|
||||
let r = try await api.issue("/peers", body)
|
||||
session.reportApply(r.applyError)
|
||||
issued = r
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "Show it here" or "Send a setup link", with the link's options. Used when
|
||||
/// a peer is created and when its config is issued again.
|
||||
struct Handover {
|
||||
var link = false
|
||||
var hours = 24
|
||||
var pin = true
|
||||
|
||||
var body: [String: Any?] {
|
||||
link ? ["delivery": "link", "linkHours": hours, "linkPIN": pin] : [:]
|
||||
}
|
||||
}
|
||||
|
||||
struct HandoverSection: View {
|
||||
@Binding var h: Handover
|
||||
var showHint = "QR code and .conf right after you tap Create. Best when the device is next to you."
|
||||
var linkHint = "A one-time link you send to the device's owner. Keys are made when the link is opened and never stored."
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker("Hand over the config", selection: $h.link) {
|
||||
Text("Show it here").tag(false)
|
||||
Text("Send a setup link").tag(true)
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
if h.link {
|
||||
Picker("Link valid for", selection: $h.hours) {
|
||||
Text("1 hour").tag(1)
|
||||
Text("24 hours").tag(24)
|
||||
Text("7 days").tag(168)
|
||||
}
|
||||
Toggle("Require a PIN", isOn: $h.pin)
|
||||
}
|
||||
} header: {
|
||||
Text("Hand over the config")
|
||||
} footer: {
|
||||
Text(h.link ? linkHint + (h.pin ? " Send the PIN by another channel than the link." : "") : showHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func qrImage(_ dataURL: String?) -> UIImage? {
|
||||
guard let s = dataURL, let comma = s.firstIndex(of: ","),
|
||||
let data = Data(base64Encoded: String(s[s.index(after: comma)...])) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
/// Shows a setup link to send: share, copy, PIN, QR code of the link.
|
||||
struct SetupLinkContent: View {
|
||||
let name: String
|
||||
let setup: SetupSecret
|
||||
@State private var copied: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Notice(text: setup.pin != nil
|
||||
? "Anyone with this link and the PIN can set up this peer once. Send the PIN separately, e.g. by phone or another messenger."
|
||||
: "Anyone with this link can set up this peer once. Send it only to the device's owner.")
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
KV(key: "Link", value: setup.url, mono: true)
|
||||
HStack(spacing: 12) {
|
||||
if let url = URL(string: setup.url) {
|
||||
ShareLink(item: url, subject: Text("VPN setup"), message: Text("Your VPN setup link for \(name)")) {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
}
|
||||
copyButton("Copy", value: setup.url)
|
||||
}
|
||||
}
|
||||
.card()
|
||||
if let pin = setup.pin {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("PIN").font(.caption).foregroundStyle(Color.gwText2)
|
||||
Text(pin).font(.mono(.title, weight: .semibold)).tracking(6).textSelection(.enabled)
|
||||
}
|
||||
Spacer()
|
||||
copyButton("Copy PIN", value: pin).frame(maxWidth: 150)
|
||||
}
|
||||
.card()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
KV(key: "Valid until", value: fmtStamp(setup.expires))
|
||||
KV(key: "Uses", value: "Once. Then the link stops working.")
|
||||
}
|
||||
.card()
|
||||
if let img = qrImage(setup.qr) {
|
||||
VStack(spacing: 8) {
|
||||
Image(uiImage: img)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 220)
|
||||
.padding(12)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
.accessibilityLabel("QR code of the setup link for \(name)")
|
||||
Text("The QR code holds only the link, not the config.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color.gwText2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
Text("Until the link is used, you can share it again or revoke it on the peer's page.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color.gwText2)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Color.gwGround)
|
||||
}
|
||||
|
||||
private func copyButton(_ title: String, value: String) -> some View {
|
||||
Button {
|
||||
UIPasteboard.general.string = value
|
||||
copied = value
|
||||
} label: {
|
||||
Label(copied == value ? "Copied" : title, systemImage: copied == value ? "checkmark" : "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(SecondaryButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of issuing: the config, or the setup link.
|
||||
struct IssueOutcomeContent: View {
|
||||
let outcome: IssueOutcome
|
||||
var body: some View {
|
||||
switch outcome {
|
||||
case .config(let c): IssuedConfigContent(issued: c)
|
||||
case .link(let l): SetupLinkContent(name: l.peer.name, setup: l.setup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func outcomeTitle(_ o: IssueOutcome) -> String {
|
||||
switch o {
|
||||
case .config(let c): "Config for \(c.peer.name)"
|
||||
case .link(let l): "Setup link for \(l.peer.name)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue a config for an existing peer: choose how to hand it over, then
|
||||
/// show the result in the same sheet.
|
||||
struct IssueSheet: View {
|
||||
let peer: Peer
|
||||
var startWithLink = false
|
||||
@Environment(AppSession.self) private var session
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var h = Handover()
|
||||
@State private var outcome: IssueOutcome?
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let outcome {
|
||||
IssueOutcomeContent(outcome: outcome)
|
||||
} else {
|
||||
Form {
|
||||
Section {
|
||||
if !peer.publicKey.isEmpty {
|
||||
Text("New keys are created. The device that uses the current config stops working once it is replaced.")
|
||||
}
|
||||
if peer.setup != nil {
|
||||
Text("This replaces the current setup link.")
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color.gwText2)
|
||||
HandoverSection(h: $h,
|
||||
showHint: "New keys now; QR code and .conf on this screen.",
|
||||
linkHint: peer.publicKey.isEmpty ? "A one-time link you send to the device's owner."
|
||||
: "The current config keeps working until the link is opened.")
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(Color.gwErrInk) }
|
||||
}
|
||||
}
|
||||
.groundBackground()
|
||||
}
|
||||
}
|
||||
.navigationTitle(outcome.map(outcomeTitle) ?? (peer.publicKey.isEmpty ? "Issue config" : "Issue new config"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if outcome == nil {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Continue") { Task { await issue() } }.disabled(busy)
|
||||
}
|
||||
} else {
|
||||
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(outcome != nil)
|
||||
.onAppear { h.link = startWithLink }
|
||||
}
|
||||
|
||||
private func issue() async {
|
||||
guard let api = session.api else { return }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
error = nil
|
||||
do {
|
||||
let o = try await api.issue("/peers/\(peer.id)/issue-config", h.link ? h.body : nil)
|
||||
session.reportApply(o.applyError)
|
||||
outcome = o
|
||||
} catch {
|
||||
self.error = session.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a pending setup link again and shows it.
|
||||
struct SetupLinkSheet: View {
|
||||
let peer: Peer
|
||||
@Environment(AppSession.self) private var session
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var setup: SetupSecret?
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let setup {
|
||||
SetupLinkContent(name: peer.name, setup: setup)
|
||||
} else if let error {
|
||||
ScrollView { Notice(text: error, isError: true).padding(16) }.background(Color.gwGround)
|
||||
} else {
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Setup link for \(peer.name)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
.task {
|
||||
guard let api = session.api else { return }
|
||||
do { setup = try await api.get("/peers/\(peer.id)/setup") } catch { self.error = session.message(for: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,12 @@ struct PrimaryButtonStyle: ButtonStyle {
|
||||
}
|
||||
|
||||
struct SecondaryButtonStyle: ButtonStyle {
|
||||
var ink = Color.gwText
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.body.weight(.medium))
|
||||
.frame(maxWidth: .infinity, minHeight: 48)
|
||||
.foregroundStyle(Color.gwText)
|
||||
.foregroundStyle(ink)
|
||||
.background(Color.gwSurface.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gwLine))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user