Add a native iOS client for relay-based file sharing
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.
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user