The SwiftUI app bundles the Go swarm engine through gomobile, so an iPhone is a real peer using the existing PAKE handshake, chunk verification, and relay fallback. XcodeGen builds the project and devicectl installs it on the paired phone.
353 lines
11 KiB
Swift
353 lines
11 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)
|
|
|
|
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 public relay.")
|
|
.font(.caption)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.foregroundStyle(Theme.tertiaryText)
|
|
|
|
if !model.relay.isEmpty {
|
|
Button("Use public relay") {
|
|
model.relay = ShareRelayDefault()
|
|
}
|
|
.buttonStyle(QuietButtonStyle(tint: Theme.mint))
|
|
}
|
|
}
|
|
.padding(.top, 13)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|