The relay now answers a stateless ping with pong. The Relay card auto-checks on appear and address changes, showing latency or the connection error; this verifies the endpoint speaks the share protocol, not just that a TCP port is open.
357 lines
10 KiB
Swift
357 lines
10 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
|
|
@Published var relayCheck: RelayCheck?
|
|
@Published var isCheckingRelay = 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 var relayCheckTask: Task<Void, Never>?
|
|
private var relayCheckGeneration = 0
|
|
|
|
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: - Relay reachability
|
|
|
|
/// Debounced automatic check used while the Relay card is visible. The
|
|
/// short delay keeps every keystroke in the relay field from opening a
|
|
/// socket.
|
|
func scheduleRelayCheck() {
|
|
relayCheckTask?.cancel()
|
|
relayCheckGeneration += 1
|
|
let generation = relayCheckGeneration
|
|
relayCheck = nil
|
|
|
|
relayCheckTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
|
guard !Task.isCancelled, let self else { return }
|
|
await self.performRelayCheck(generation: generation)
|
|
}
|
|
}
|
|
|
|
/// Immediate check, used by the refresh button.
|
|
func checkRelay() {
|
|
relayCheckTask?.cancel()
|
|
relayCheckGeneration += 1
|
|
let generation = relayCheckGeneration
|
|
relayCheckTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.performRelayCheck(generation: generation)
|
|
}
|
|
}
|
|
|
|
private func performRelayCheck(generation: Int) async {
|
|
isCheckingRelay = true
|
|
let relay = relay
|
|
|
|
// ShareCheckRelay performs a dial plus the protocol ping/pong. Keep
|
|
// that off the main thread; the Go function has its own 5s timeouts.
|
|
let json = await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
|
|
DispatchQueue.global(qos: .utility).async {
|
|
continuation.resume(returning: ShareCheckRelay(relay))
|
|
}
|
|
}
|
|
|
|
guard generation == relayCheckGeneration else { return }
|
|
isCheckingRelay = false
|
|
|
|
guard let data = json.data(using: .utf8),
|
|
let decoded = try? JSONDecoder().decode(RelayCheck.self, from: data) else {
|
|
relayCheck = RelayCheck(
|
|
ok: false,
|
|
relay: relay.isEmpty ? ShareRelayDefault() : relay,
|
|
latencyMs: 0,
|
|
error: "Could not read the relay status."
|
|
)
|
|
return
|
|
}
|
|
relayCheck = decoded
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|