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.
419 lines
13 KiB
Swift
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"
|
|
}
|
|
}
|