Files
share/apps/ios/Share/AppModel.swift
T
TZGyn 9a37e93cfc 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.
2026-10-26 22:24:47 +08:00

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)
}
}