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,295 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@preconcurrency import Share
|
||||
|
||||
/// The app's single source of truth. One Go session is alive at a time;
|
||||
/// Swift polls its JSON status on the main run loop and publishes plain
|
||||
/// value types to the views, so no gomobile object ever reaches SwiftUI.
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
@Published var mode: RoomMode = .send
|
||||
@Published var status: RoomStatus?
|
||||
@Published var pickedFiles: [PickedFile] = []
|
||||
@Published var code: String = ShareGenerateCode()
|
||||
@Published var relay: String = UserDefaults.standard.string(forKey: Keys.relay)
|
||||
?? ShareRelayDefault() {
|
||||
didSet { UserDefaults.standard.set(relay, forKey: Keys.relay) }
|
||||
}
|
||||
@Published var isBusy = false
|
||||
@Published var isImporting = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var notice: String?
|
||||
@Published var showAdvanced = false
|
||||
|
||||
private let session: ShareSession = ShareNewSession()!
|
||||
private let receivedDir: URL
|
||||
private let seedsDir: URL
|
||||
private var pollTimer: Timer?
|
||||
private var noticeTask: Task<Void, Never>?
|
||||
|
||||
private enum Keys {
|
||||
static let relay = "share.relay"
|
||||
}
|
||||
|
||||
init() {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
receivedDir = documents.appendingPathComponent("Received", isDirectory: true)
|
||||
seedsDir = documents.appendingPathComponent("Seeds", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: receivedDir, withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: seedsDir, withIntermediateDirectories: true)
|
||||
refresh()
|
||||
}
|
||||
|
||||
// MARK: - Setup state
|
||||
|
||||
var hasRoom: Bool { status != nil }
|
||||
|
||||
var trimmedCode: String {
|
||||
code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var canStartSend: Bool {
|
||||
!pickedFiles.isEmpty && !isBusy && !hasRoom
|
||||
}
|
||||
|
||||
var canStartReceive: Bool {
|
||||
!trimmedCode.isEmpty && !isBusy && !hasRoom
|
||||
}
|
||||
|
||||
var isWaitingForPeer: Bool {
|
||||
guard let status, status.running else { return false }
|
||||
return status.peers == 0
|
||||
}
|
||||
|
||||
var modeTitle: String {
|
||||
switch mode {
|
||||
case .send: return pickedFiles.isEmpty ? "Share files" : "Sending"
|
||||
case .receive: return "Receiving"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picking files
|
||||
|
||||
func handleImport(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
importFiles(urls)
|
||||
case .failure(let error):
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func importFiles(_ urls: [URL]) {
|
||||
guard !urls.isEmpty else { return }
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
let seedsDir = seedsDir
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var imported: [PickedFile] = []
|
||||
var failure: String?
|
||||
|
||||
for url in urls {
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if scoped { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
do {
|
||||
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey])
|
||||
if values.isDirectory == true {
|
||||
failure = "Folders aren't supported yet — pick individual files."
|
||||
continue
|
||||
}
|
||||
|
||||
// Copy into the app container before handing the path to Go.
|
||||
// The picker's security-scoped URL is only valid while this
|
||||
// call is on the stack, not for the lifetime of the room.
|
||||
let destinationDir = seedsDir.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: destinationDir, withIntermediateDirectories: true)
|
||||
let destination = destinationDir.appendingPathComponent(url.lastPathComponent)
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
|
||||
let size = Int64((try? destination.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
|
||||
imported.append(PickedFile(url: destination, name: destination.lastPathComponent, size: size))
|
||||
} catch {
|
||||
failure = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.pickedFiles.append(contentsOf: imported)
|
||||
self.isBusy = false
|
||||
if let failure { self.errorMessage = failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePickedFile(_ file: PickedFile) {
|
||||
pickedFiles.removeAll { $0.id == file.id }
|
||||
try? FileManager.default.removeItem(at: file.url.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
func clearPickedFiles() {
|
||||
for file in pickedFiles {
|
||||
try? FileManager.default.removeItem(at: file.url.deletingLastPathComponent())
|
||||
}
|
||||
pickedFiles = []
|
||||
}
|
||||
|
||||
// MARK: - Starting a room
|
||||
|
||||
func startSend() {
|
||||
guard !pickedFiles.isEmpty else {
|
||||
errorMessage = "Choose at least one file first."
|
||||
return
|
||||
}
|
||||
guard let filesJSON = encodePaths(pickedFiles.map(\.url.path)) else {
|
||||
errorMessage = "Could not prepare the file list."
|
||||
return
|
||||
}
|
||||
let roomCode = trimmedCode.isEmpty ? ShareGenerateCode() : trimmedCode
|
||||
code = roomCode
|
||||
start(relay: relay, code: roomCode, filesJSON: filesJSON,
|
||||
outDir: receivedDir.path, oneTime: false)
|
||||
}
|
||||
|
||||
func startReceive() {
|
||||
guard !trimmedCode.isEmpty else {
|
||||
errorMessage = "Enter the room code shown on the other device."
|
||||
return
|
||||
}
|
||||
start(relay: relay, code: trimmedCode, filesJSON: "[]",
|
||||
outDir: receivedDir.path, oneTime: true)
|
||||
}
|
||||
|
||||
private func start(relay: String, code: String, filesJSON: String,
|
||||
outDir: String, oneTime: Bool) {
|
||||
errorMessage = nil
|
||||
isBusy = true
|
||||
stopPolling()
|
||||
let session = session
|
||||
|
||||
// Start dials the relay, which can take up to the dial timeout. Keep
|
||||
// that off the main thread so the button can show its spinner.
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try session.start(relay, code: code, filesJSON: filesJSON,
|
||||
outDir: outDir, oneTime: oneTime)
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
self.refresh()
|
||||
self.startPolling()
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRoom() {
|
||||
session.stop()
|
||||
refresh()
|
||||
stopPolling()
|
||||
flash("Room stopped")
|
||||
}
|
||||
|
||||
func reset() {
|
||||
session.stop()
|
||||
stopPolling()
|
||||
clearPickedFiles()
|
||||
status = nil
|
||||
errorMessage = nil
|
||||
code = ShareGenerateCode()
|
||||
}
|
||||
|
||||
func newCode() {
|
||||
code = ShareGenerateCode()
|
||||
}
|
||||
|
||||
// MARK: - Status polling
|
||||
|
||||
private func startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 0.6, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
guard let data = session.statusJSON().data(using: .utf8) else { return }
|
||||
guard let decoded = try? JSONDecoder().decode(RoomStatus.self, from: data) else { return }
|
||||
|
||||
// An idle Go session has an empty code. Don't turn that into a room
|
||||
// card before the user has started anything.
|
||||
if decoded.code.isEmpty && !decoded.running {
|
||||
return
|
||||
}
|
||||
|
||||
status = decoded
|
||||
if !decoded.running {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
func handleScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .active:
|
||||
refresh()
|
||||
if status != nil { startPolling() }
|
||||
case .background, .inactive:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Received files
|
||||
|
||||
func receivedURL(for file: FileProgress) -> URL? {
|
||||
guard file.complete else { return nil }
|
||||
let url = receivedDir.appendingPathComponent(file.name)
|
||||
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
||||
}
|
||||
|
||||
// MARK: - Small helpers
|
||||
|
||||
func copyCode() {
|
||||
UIPasteboard.general.string = status?.code ?? trimmedCode
|
||||
flash("Code copied")
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
func pasteCode() {
|
||||
guard let value = UIPasteboard.general.string?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
errorMessage = "The clipboard is empty."
|
||||
return
|
||||
}
|
||||
code = value
|
||||
}
|
||||
|
||||
func flash(_ message: String) {
|
||||
notice = message
|
||||
noticeTask?.cancel()
|
||||
noticeTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.notice = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func encodePaths(_ paths: [String]) -> String? {
|
||||
guard let data = try? JSONEncoder().encode(paths) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.831",
|
||||
"green" : "0.918",
|
||||
"red" : "0.369"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 887 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, opacity: Double = 1) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: opacity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum Theme {
|
||||
static let background = Color.black
|
||||
static let card = Color.white.opacity(0.055)
|
||||
static let cardStrong = Color.white.opacity(0.08)
|
||||
static let stroke = Color.white.opacity(0.09)
|
||||
static let mint = Color(hex: 0x5EEAD4)
|
||||
static let blue = Color(hex: 0x60A5FA)
|
||||
static let amber = Color(hex: 0xFBBF24)
|
||||
static let danger = Color(hex: 0xFB7185)
|
||||
static let secondaryText = Color.white.opacity(0.62)
|
||||
static let tertiaryText = Color.white.opacity(0.4)
|
||||
}
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
Theme.background.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct LogoMark: View {
|
||||
var size: CGFloat = 44
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: size * 0.29, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Theme.mint, Theme.blue],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.shadow(color: Theme.mint.opacity(0.35), radius: 16, y: 6)
|
||||
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.font(.system(size: size * 0.4, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x06211C))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassCard<Content: View>: View {
|
||||
private let padding: CGFloat
|
||||
private let content: Content
|
||||
|
||||
init(padding: CGFloat = 20, @ViewBuilder content: () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Wrap the builder's tuple in a real layout container. Applying the
|
||||
// card background directly to a TupleView lets SwiftUI flatten the
|
||||
// children into the parent VStack, which visually breaks one card into
|
||||
// several rounded boxes.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.card, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FieldLabel: View {
|
||||
let title: String
|
||||
var hint: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
if let hint {
|
||||
Text(hint)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrimaryButtonStyle: ButtonStyle {
|
||||
var tint: Color = Theme.mint
|
||||
var enabled: Bool = true
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Color(hex: 0x06211C))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 15)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: enabled ? [tint, tint.opacity(0.78)] : [Color.white.opacity(0.16), Color.white.opacity(0.12)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
.shadow(color: enabled ? tint.opacity(0.24) : .clear, radius: 14, y: 6)
|
||||
.scaleEffect(configuration.isPressed && enabled ? 0.985 : 1)
|
||||
.opacity(enabled ? 1 : 0.55)
|
||||
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
struct QuietButtonStyle: ButtonStyle {
|
||||
var tint: Color = Theme.secondaryText
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(tint)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.white.opacity(0.06), in: Capsule())
|
||||
.opacity(configuration.isPressed ? 0.65 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusPill: View {
|
||||
let running: Bool
|
||||
let peers: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(running ? Theme.mint : Theme.tertiaryText)
|
||||
.frame(width: 7, height: 7)
|
||||
.shadow(color: running ? Theme.mint.opacity(0.8) : .clear, radius: 5)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(running ? Theme.mint : Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
(running ? Theme.mint : Color.white).opacity(0.08),
|
||||
in: Capsule()
|
||||
)
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
guard running else { return "Stopped" }
|
||||
if peers == 0 { return "Waiting for peer" }
|
||||
return "Live · \(peers) peer\(peers == 1 ? "" : "s")"
|
||||
}
|
||||
}
|
||||
|
||||
struct FileRow: View {
|
||||
let file: FileProgress
|
||||
let shareURL: URL?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Theme.blue.opacity(0.12))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: file.complete ? "checkmark" : "doc.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(file.complete ? Theme.mint : 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(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if let shareURL {
|
||||
ShareLink(item: shareURL) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Theme.mint.opacity(0.1), in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Share \(file.name)")
|
||||
}
|
||||
}
|
||||
|
||||
if !file.complete {
|
||||
ProgressView(value: file.complete ? 1 : file.fraction)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Theme.blue)
|
||||
.scaleEffect(x: 1, y: 0.75, anchor: .center)
|
||||
}
|
||||
}
|
||||
.padding(13)
|
||||
.background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if file.complete { return "\(file.formattedSize) · Complete" }
|
||||
let percent = Int((file.fraction * 100).rounded())
|
||||
return "\(file.formattedSize) · \(percent)% · \(file.haveChunks)/\(file.totalChunks) chunks"
|
||||
}
|
||||
}
|
||||
|
||||
struct ToastView: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.mint)
|
||||
Text(message)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 11)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay { Capsule().strokeBorder(Theme.stroke, lineWidth: 1) }
|
||||
.shadow(color: .black.opacity(0.3), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct PulsingDot: View {
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.mint.opacity(0.18))
|
||||
.frame(width: 28, height: 28)
|
||||
.scaleEffect(animate ? 1.25 : 0.8)
|
||||
.opacity(animate ? 0 : 1)
|
||||
Circle()
|
||||
.fill(Theme.mint)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 1.4).repeatForever(autoreverses: false)) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Share</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Share</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Share connects directly to nearby devices on your local network. If a direct connection is not possible, it uses the relay instead.</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
/// Which side of the room the setup screen is preparing. Once a room is
|
||||
/// running the distinction only changes labels: every peer can send and
|
||||
/// receive, just like the CLI.
|
||||
enum RoomMode: String, CaseIterable, Identifiable {
|
||||
case send
|
||||
case receive
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .send: return "Send"
|
||||
case .receive: return "Receive"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .send: return "arrow.up.circle.fill"
|
||||
case .receive: return "arrow.down.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One file's progress as reported by the Go engine. The JSON field names
|
||||
/// match `swarm.FileStatus`; no conversion layer is needed.
|
||||
struct FileProgress: Codable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let size: Int64
|
||||
let haveChunks: Int64
|
||||
let totalChunks: Int64
|
||||
let complete: Bool
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var fraction: Double {
|
||||
guard totalChunks > 0 else { return 0 }
|
||||
return min(1, max(0, Double(haveChunks) / Double(totalChunks)))
|
||||
}
|
||||
|
||||
var formattedSize: String {
|
||||
ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole room snapshot decoded from `ShareSession.statusJSON()`. The
|
||||
/// Go bridge keeps the same shape across launches so the UI never has to
|
||||
/// know about gomobile objects beyond the session itself.
|
||||
struct RoomStatus: Codable {
|
||||
var code: String
|
||||
var relay: String
|
||||
var outDir: String
|
||||
var running: Bool
|
||||
var peers: Int
|
||||
var files: [FileProgress]
|
||||
var error: String?
|
||||
|
||||
var allComplete: Bool {
|
||||
!files.isEmpty && files.allSatisfy(\.complete)
|
||||
}
|
||||
|
||||
var receivedFiles: [FileProgress] {
|
||||
files.filter(\.complete)
|
||||
}
|
||||
}
|
||||
|
||||
/// A file that has already been copied into the app's seed directory.
|
||||
/// Holding the copied URL (rather than the security-scoped picker URL)
|
||||
/// means the Go engine can keep reading it after the picker goes away.
|
||||
struct PickedFile: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
let name: String
|
||||
let size: Int64
|
||||
|
||||
var formattedSize: String {
|
||||
ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RoomView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
if let status = model.status {
|
||||
VStack(spacing: 16) {
|
||||
overviewCard(status)
|
||||
codeCard(status)
|
||||
filesCard(status)
|
||||
actionButton(status)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: status.files)
|
||||
.animation(.easeInOut(duration: 0.22), value: status.running)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overview
|
||||
|
||||
private func overviewCard(_ status: RoomStatus) -> some View {
|
||||
GlassCard {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: model.mode == .send
|
||||
? [Theme.mint.opacity(0.2), Theme.mint.opacity(0.06)]
|
||||
: [Theme.blue.opacity(0.2), Theme.blue.opacity(0.06)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 46, height: 46)
|
||||
Image(systemName: model.mode == .send ? "arrow.up" : "arrow.down")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(model.mode == .send ? Theme.mint : Theme.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(roomTitle(status))
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(roomSubtitle(status))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
StatusPill(running: status.running, peers: status.peers)
|
||||
}
|
||||
|
||||
if status.running {
|
||||
HStack(spacing: 9) {
|
||||
if model.isWaitingForPeer {
|
||||
PulsingDot()
|
||||
Text("Waiting for the other device to join")
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.mint)
|
||||
Text("Connected · \(status.peers) peer\(status.peers == 1 ? "" : "s")")
|
||||
}
|
||||
}
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 14)
|
||||
} else if let error = status.error, !error.isEmpty {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Theme.danger)
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.danger.opacity(0.95))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 13)
|
||||
} else if model.mode == .receive && status.allComplete {
|
||||
Label(
|
||||
"All files received",
|
||||
systemImage: "checkmark.seal.fill"
|
||||
)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.padding(.top, 13)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func roomTitle(_ status: RoomStatus) -> String {
|
||||
if !status.running {
|
||||
if let error = status.error, !error.isEmpty { return "Room stopped" }
|
||||
if model.mode == .receive && status.allComplete { return "Room complete" }
|
||||
return "Room stopped"
|
||||
}
|
||||
return model.mode == .send ? "Sharing files" : "Receiving files"
|
||||
}
|
||||
|
||||
private func roomSubtitle(_ status: RoomStatus) -> String {
|
||||
if !status.running {
|
||||
if let error = status.error, !error.isEmpty {
|
||||
return "The connection to the relay was lost."
|
||||
}
|
||||
if model.mode == .receive && status.allComplete {
|
||||
return "The files are safe on your devices."
|
||||
}
|
||||
return "The room is no longer connected."
|
||||
}
|
||||
if model.mode == .send {
|
||||
return "Keep this screen open while the other device downloads."
|
||||
}
|
||||
return "Files will appear here as they arrive."
|
||||
}
|
||||
|
||||
// MARK: - Code
|
||||
|
||||
private func codeCard(_ status: RoomStatus) -> some View {
|
||||
GlassCard {
|
||||
FieldLabel(title: "Room code", hint: "The other device needs this exact code.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(status.code)
|
||||
.font(.system(size: 21, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.55)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
.onTapGesture { model.copyCode() }
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint("Copies the room code")
|
||||
|
||||
Button {
|
||||
model.copyCode()
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(Theme.mint.opacity(0.1), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Copy room code")
|
||||
|
||||
ShareLink(item: status.code) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.blue)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(Theme.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
}
|
||||
.accessibilityLabel("Share room code")
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "network")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
Text(status.relay.isEmpty ? "public relay" : status.relay)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.font(.system(size: 10.5, design: .monospaced))
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
private func filesCard(_ status: RoomStatus) -> some View {
|
||||
GlassCard {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Files")
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
if !status.files.isEmpty {
|
||||
Text("\(status.files.count)")
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.white.opacity(0.06), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
if status.files.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
if status.running {
|
||||
PulsingDot()
|
||||
Text("Waiting for files…")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
} else {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
Text("No files in this room")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 26)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(sortedFiles(status.files)) { file in
|
||||
FileRow(
|
||||
file: file,
|
||||
shareURL: model.mode == .receive ? model.receivedURL(for: file) : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 13)
|
||||
|
||||
if model.mode == .receive && status.allComplete {
|
||||
Text("Tap the share button to save a file to Photos, Files, or another app.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sortedFiles(_ files: [FileProgress]) -> [FileProgress] {
|
||||
files.sorted {
|
||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func actionButton(_ status: RoomStatus) -> some View {
|
||||
Group {
|
||||
if status.running {
|
||||
Button {
|
||||
model.stopRoom()
|
||||
} label: {
|
||||
Label("Stop room", systemImage: "stop.fill")
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Theme.danger)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
Theme.danger.opacity(0.09),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Theme.danger.opacity(0.18), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
model.reset()
|
||||
} label: {
|
||||
Label("Start another room", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle(tint: Theme.mint))
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var showImporter = false
|
||||
@Namespace private var modeNamespace
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
header
|
||||
modePicker
|
||||
content
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 36)
|
||||
.frame(maxWidth: 620)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
|
||||
if let notice = model.notice {
|
||||
VStack {
|
||||
Spacer()
|
||||
ToastView(message: notice)
|
||||
.padding(.bottom, 24)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: model.notice)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showImporter,
|
||||
allowedContentTypes: [.data],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
model.handleImport(result)
|
||||
}
|
||||
.alert(
|
||||
"Something went wrong",
|
||||
isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
model.handleScenePhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 13) {
|
||||
LogoMark()
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("share")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
Text("direct device-to-device transfer")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(RoomMode.allCases) { mode in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||
model.mode = mode
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: mode.icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(mode.title)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.foregroundStyle(
|
||||
model.mode == mode ? Color(hex: 0x06211C) : Theme.secondaryText
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
if model.mode == mode {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: mode == .send
|
||||
? [Theme.mint, Theme.mint.opacity(0.8)]
|
||||
: [Theme.blue, Theme.blue.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.matchedGeometryEffect(id: "mode", in: modeNamespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityAddTraits(model.mode == mode ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(4)
|
||||
.background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
.disabled(model.hasRoom || model.isBusy)
|
||||
.opacity(model.hasRoom || model.isBusy ? 0.55 : 1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if model.hasRoom {
|
||||
RoomView()
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .move(edge: .leading).combined(with: .opacity)
|
||||
))
|
||||
} else {
|
||||
SetupView(showImporter: $showImporter)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .leading).combined(with: .opacity),
|
||||
removal: .move(edge: .trailing).combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.shield")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text("End-to-end encrypted with a room-code key")
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
}
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
|
||||
Text("The relay only helps peers find each other. File data goes directly between devices, or through an encrypted relay pipe when a direct path is blocked.")
|
||||
.font(.system(size: 10.5, design: .rounded))
|
||||
.foregroundStyle(Theme.tertiaryText.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// A headless-ish smoke test driven by launch arguments:
|
||||
///
|
||||
/// xcrun simctl launch <udid> dev.tzgyn.share \
|
||||
/// -shareSelfTest -shareSelfTestRelay 127.0.0.1:9009 \
|
||||
/// -shareSelfTestCode 1234-selftest-share-apple
|
||||
///
|
||||
/// The test starts a receive room, waits for one complete file, and writes
|
||||
/// `Documents/share-selftest.txt`. Console output is unreliable from a
|
||||
/// simulator and impossible to read from a device, so the report on disk is
|
||||
/// the source of truth.
|
||||
@MainActor
|
||||
enum SelfTest {
|
||||
private static var started = false
|
||||
|
||||
static func runIfRequested(model: AppModel) {
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
guard !started else { return }
|
||||
let relay = value(after: "-shareSelfTestRelay", in: args) ?? "127.0.0.1:9009"
|
||||
let code = value(after: "-shareSelfTestCode", in: args)
|
||||
?? (args.contains("-shareSelfTestSend")
|
||||
? "1234-selftest-send-apple"
|
||||
: "1234-selftest-share-apple")
|
||||
|
||||
if args.contains("-shareSelfTestSend") {
|
||||
started = true
|
||||
runSend(model: model, relay: relay, code: code)
|
||||
} else if args.contains("-shareSelfTest") {
|
||||
started = true
|
||||
runReceive(model: model, relay: relay, code: code)
|
||||
}
|
||||
}
|
||||
|
||||
private static func runReceive(model: AppModel, relay: String, code: String) {
|
||||
model.mode = .receive
|
||||
model.relay = relay
|
||||
model.code = code
|
||||
model.startReceive()
|
||||
|
||||
Task { @MainActor in
|
||||
let deadline = Date().addingTimeInterval(120)
|
||||
var lastStatus: RoomStatus?
|
||||
|
||||
while Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
if let status = model.status {
|
||||
lastStatus = status
|
||||
if status.allComplete {
|
||||
writeReport(result: "ok", model: model, status: status, error: nil)
|
||||
return
|
||||
}
|
||||
if !status.running, let error = status.error, !error.isEmpty {
|
||||
writeReport(result: "room-error", model: model, status: status, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
if model.status == nil, let error = model.errorMessage, !error.isEmpty {
|
||||
writeReport(result: "start-error", model: model, status: nil, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeReport(
|
||||
result: "timeout",
|
||||
model: model,
|
||||
status: lastStatus,
|
||||
error: "no file completed before the 120s deadline"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func runSend(model: AppModel, relay: String, code: String) {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let seedDir = documents.appendingPathComponent("Seeds/selftest-send", isDirectory: true)
|
||||
let seedURL = seedDir.appendingPathComponent("share-send-selftest.bin")
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: seedDir, withIntermediateDirectories: true)
|
||||
let data = Data(repeating: 0x5A, count: 2 * 1024 * 1024)
|
||||
try data.write(to: seedURL)
|
||||
} catch {
|
||||
writeReport(result: "seed-error", model: model, status: nil, error: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
model.mode = .send
|
||||
model.relay = relay
|
||||
model.code = code
|
||||
model.pickedFiles = [
|
||||
PickedFile(url: seedURL, name: seedURL.lastPathComponent, size: 2 * 1024 * 1024)
|
||||
]
|
||||
model.startSend()
|
||||
|
||||
Task { @MainActor in
|
||||
let deadline = Date().addingTimeInterval(120)
|
||||
var lastStatus: RoomStatus?
|
||||
var sawPeer = false
|
||||
|
||||
while Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
if let status = model.status {
|
||||
lastStatus = status
|
||||
if status.peers > 0 { sawPeer = true }
|
||||
if sawPeer, status.allComplete {
|
||||
// Give the receiver a moment to finish the last chunks and
|
||||
// disconnect before recording the result.
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
writeReport(result: "ok-seeded", model: model, status: status, error: nil)
|
||||
return
|
||||
}
|
||||
if !status.running, let error = status.error, !error.isEmpty {
|
||||
writeReport(result: "room-error", model: model, status: status, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
if model.status == nil, let error = model.errorMessage, !error.isEmpty {
|
||||
writeReport(result: "start-error", model: model, status: nil, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeReport(
|
||||
result: "timeout",
|
||||
model: model,
|
||||
status: lastStatus,
|
||||
error: "no peer completed the seed before the 120s deadline"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func value(after flag: String, in args: [String]) -> String? {
|
||||
guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil }
|
||||
return args[index + 1]
|
||||
}
|
||||
|
||||
private static func writeReport(
|
||||
result: String,
|
||||
model: AppModel,
|
||||
status: RoomStatus?,
|
||||
error: String?
|
||||
) {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let url = documents.appendingPathComponent("share-selftest.txt")
|
||||
|
||||
var lines = [
|
||||
"result=\(result)",
|
||||
"code=\(status?.code ?? model.code)",
|
||||
"relay=\(status?.relay ?? model.relay)",
|
||||
"running=\(status?.running ?? false)",
|
||||
"peers=\(status?.peers ?? 0)",
|
||||
"files=\(status?.files.count ?? 0)",
|
||||
]
|
||||
if let error, !error.isEmpty {
|
||||
lines.append("error=\(error.replacingOccurrences(of: "\n", with: " "))")
|
||||
}
|
||||
for file in status?.files ?? [] {
|
||||
let localURL: URL?
|
||||
if model.mode == .send {
|
||||
localURL = model.pickedFiles.first(where: { $0.name == file.name })?.url
|
||||
} else {
|
||||
localURL = model.receivedURL(for: file)
|
||||
}
|
||||
let exists = localURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
|
||||
let size = localURL.flatMap {
|
||||
try? $0.resourceValues(forKeys: [.fileSizeKey]).fileSize
|
||||
} ?? 0
|
||||
lines.append(
|
||||
"file=\(file.name) size=\(file.size) chunks=\(file.haveChunks)/\(file.totalChunks) complete=\(file.complete) exists=\(exists) diskSize=\(size)"
|
||||
)
|
||||
}
|
||||
|
||||
try? lines.joined(separator: "\n").appending("\n").write(
|
||||
to: url,
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
print("Share self-test \(result): \(url.path)")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ShareApp: App {
|
||||
@StateObject private var model: AppModel
|
||||
|
||||
init() {
|
||||
let model = AppModel()
|
||||
_model = StateObject(wrappedValue: model)
|
||||
Task { @MainActor in
|
||||
SelfTest.runIfRequested(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environmentObject(model)
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(Theme.mint)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user