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.
296 lines
8.5 KiB
Swift
296 lines
8.5 KiB
Swift
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)
|
|
}
|
|
}
|