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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user