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