Files
share/apps/ios/Share/SetupView.swift
T
TZGyn bf5d19900c Add a relay ping and backend status to the iOS app
The relay now answers a stateless ping with pong. The Relay card auto-checks on appear and address changes, showing latency or the connection error; this verifies the endpoint speaks the share protocol, not just that a TCP port is open.
2026-10-27 21:59:27 +08:00

419 lines
13 KiB
Swift

import SwiftUI
import Share
struct SetupView: View {
@EnvironmentObject private var model: AppModel
@Binding var showImporter: Bool
var body: some View {
VStack(spacing: 16) {
if model.mode == .send {
filesCard
}
codeCard
relayCard
startButton
}
.animation(.easeInOut(duration: 0.22), value: model.mode)
}
// MARK: - Files
private var filesCard: some View {
GlassCard {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text("Files")
.font(.system(.headline, design: .rounded).weight(.semibold))
.foregroundStyle(.white)
Text("Everything stays on your devices.")
.font(.footnote)
.foregroundStyle(Theme.secondaryText)
}
Spacer()
if !model.pickedFiles.isEmpty {
Button("Clear") {
withAnimation(.easeInOut(duration: 0.2)) { model.clearPickedFiles() }
}
.buttonStyle(QuietButtonStyle(tint: Theme.secondaryText))
}
}
if model.pickedFiles.isEmpty {
chooseButton
.padding(.top, 14)
} else {
VStack(spacing: 8) {
ForEach(model.pickedFiles) { file in
pickedFileRow(file)
}
}
.padding(.top, 14)
Button {
showImporter = true
} label: {
Label("Add more files", systemImage: "plus")
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.foregroundStyle(Theme.mint)
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
.background(Theme.mint.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.padding(.top, 10)
.disabled(model.isBusy)
}
}
}
private var chooseButton: some View {
Button {
showImporter = true
} label: {
VStack(spacing: 10) {
ZStack {
Circle()
.fill(Theme.mint.opacity(0.11))
.frame(width: 54, height: 54)
if model.isBusy {
ProgressView()
.tint(Theme.mint)
} else {
Image(systemName: "folder.badge.plus")
.font(.system(size: 21, weight: .medium))
.foregroundStyle(Theme.mint)
}
}
VStack(spacing: 3) {
Text(model.isBusy ? "Preparing files…" : "Choose files")
.font(.system(.headline, design: .rounded).weight(.semibold))
.foregroundStyle(.white)
Text("Any file type · multiple selection")
.font(.footnote)
.foregroundStyle(Theme.secondaryText)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.white.opacity(0.025))
)
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(
Theme.mint.opacity(0.35),
style: StrokeStyle(lineWidth: 1.2, dash: [6, 5])
)
}
}
.buttonStyle(.plain)
.disabled(model.isBusy)
}
private func pickedFileRow(_ file: PickedFile) -> some View {
HStack(spacing: 11) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Theme.blue.opacity(0.12))
.frame(width: 34, height: 34)
Image(systemName: "doc.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.blue)
}
VStack(alignment: .leading, spacing: 2) {
Text(file.name)
.font(.system(.subheadline, design: .rounded).weight(.medium))
.foregroundStyle(.white.opacity(0.92))
.lineLimit(1)
.truncationMode(.middle)
Text(file.formattedSize)
.font(.caption)
.foregroundStyle(Theme.secondaryText)
}
Spacer(minLength: 8)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
model.removePickedFile(file)
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
.frame(width: 28, height: 28)
.background(Color.white.opacity(0.06), in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Remove \(file.name)")
}
.padding(10)
.background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
// MARK: - Code
private var codeCard: some View {
GlassCard {
HStack(alignment: .firstTextBaseline) {
FieldLabel(
title: "Room code",
hint: model.mode == .send
? "Share this with the device that should receive the files."
: "Enter the code shown on the sending device."
)
Spacer(minLength: 12)
if model.mode == .send {
Button {
withAnimation(.easeInOut(duration: 0.2)) { model.newCode() }
} label: {
Label("New", systemImage: "arrow.clockwise")
}
.buttonStyle(QuietButtonStyle(tint: Theme.mint))
.accessibilityLabel("Generate a new code")
} else {
Button {
model.pasteCode()
} label: {
Label("Paste", systemImage: "doc.on.clipboard")
}
.buttonStyle(QuietButtonStyle(tint: Theme.blue))
.accessibilityLabel("Paste a code")
}
}
TextField("1234-word-word-word-word", text: $model.code)
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundStyle(.white)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.asciiCapable)
.submitLabel(model.mode == .send ? .go : .join)
.onSubmit {
if model.mode == .send {
model.startSend()
} else {
model.startReceive()
}
}
.padding(.horizontal, 13)
.padding(.vertical, 12)
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 13, style: .continuous)
.strokeBorder(Theme.stroke, lineWidth: 1)
}
.padding(.top, 12)
if model.mode == .send {
Text("On the Mac, run `share \(model.trimmedCode.isEmpty ? "…" : model.trimmedCode)`")
.font(.system(size: 11.5, design: .monospaced))
.foregroundStyle(Theme.tertiaryText)
.padding(.top, 9)
.lineLimit(2)
} else {
Text("The code is also the encryption passphrase. It never leaves your devices.")
.font(.caption)
.foregroundStyle(Theme.tertiaryText)
.padding(.top, 9)
}
}
}
// MARK: - Relay
private var relayCard: some View {
GlassCard(padding: 16) {
Button {
withAnimation(.easeInOut(duration: 0.22)) {
model.showAdvanced.toggle()
}
} label: {
HStack(spacing: 11) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Theme.blue.opacity(0.1))
.frame(width: 34, height: 34)
Image(systemName: "network")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.blue)
}
VStack(alignment: .leading, spacing: 2) {
Text("Relay")
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.foregroundStyle(.white)
Text(model.relay.isEmpty ? ShareRelayDefault() : model.relay)
.font(.system(size: 11.5, design: .monospaced))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.tertiaryText)
.rotationEffect(.degrees(model.showAdvanced ? 180 : 0))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
relayStatusRow
if model.showAdvanced {
VStack(alignment: .leading, spacing: 9) {
TextField("host:port", text: $model.relay)
.font(.system(.subheadline, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Theme.stroke, lineWidth: 1)
}
HStack(alignment: .top, spacing: 7) {
Image(systemName: "info.circle")
.font(.system(size: 11, weight: .semibold))
.padding(.top, 1)
Text("Use your Mac's LAN address, like 192.168.1.20:9009, when you run `share relay` there. Leave it empty for the tzgyn relay.")
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundStyle(Theme.tertiaryText)
if !model.relay.isEmpty {
Button("Use tzgyn relay") {
model.relay = ShareRelayDefault()
}
.buttonStyle(QuietButtonStyle(tint: Theme.mint))
}
}
.padding(.top, 13)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.task { model.scheduleRelayCheck() }
.onChange(of: model.relay) { _ in model.scheduleRelayCheck() }
}
private var relayStatusRow: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 7) {
if model.isCheckingRelay {
ProgressView()
.controlSize(.mini)
.tint(Theme.blue)
} else {
Circle()
.fill(relayStatusColor)
.frame(width: 7, height: 7)
}
Text(relayStatusText)
.font(.system(size: 11.5, weight: .medium, design: .rounded))
.foregroundStyle(relayStatusColor)
.lineLimit(1)
Spacer(minLength: 8)
Button {
model.checkRelay()
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
.frame(width: 28, height: 28)
.background(Color.white.opacity(0.05), in: Circle())
}
.buttonStyle(.plain)
.disabled(model.isCheckingRelay)
.accessibilityLabel("Check backend connectivity")
}
if let error = model.relayCheck?.error,
!error.isEmpty,
model.relayCheck?.ok == false {
Text(error)
.font(.system(size: 10, design: .rounded))
.foregroundStyle(Theme.danger.opacity(0.85))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.top, 10)
}
private var relayStatusColor: Color {
if model.isCheckingRelay { return Theme.blue }
guard let check = model.relayCheck else { return Theme.tertiaryText }
return check.ok ? Theme.mint : Theme.danger
}
private var relayStatusText: String {
if model.isCheckingRelay { return "Checking backend…" }
guard let check = model.relayCheck else { return "Backend status unknown" }
if check.ok {
return "Backend reachable · \(check.latencyMs) ms"
}
return "Backend unreachable"
}
// MARK: - Start
private var startButton: some View {
Button {
if model.mode == .send {
model.startSend()
} else {
model.startReceive()
}
} label: {
HStack(spacing: 9) {
if model.isBusy && !model.hasRoom {
ProgressView()
.tint(Color(hex: 0x06211C))
} else {
Image(systemName: model.mode == .send ? "paperplane.fill" : "arrow.down.circle.fill")
.font(.system(size: 15, weight: .semibold))
}
Text(buttonTitle)
}
}
.buttonStyle(
PrimaryButtonStyle(
tint: model.mode == .send ? Theme.mint : Theme.blue,
enabled: canStart
)
)
.disabled(!canStart)
.padding(.top, 2)
}
private var canStart: Bool {
model.mode == .send ? model.canStartSend : model.canStartReceive
}
private var buttonTitle: String {
if model.isBusy && !model.hasRoom {
return model.pickedFiles.isEmpty ? "Preparing…" : "Joining relay…"
}
if model.mode == .send {
return model.pickedFiles.isEmpty ? "Choose files to start" : "Start sharing"
}
return model.trimmedCode.isEmpty ? "Enter a room code" : "Join room"
}
}