Files
tether/ios/MacTetherApp/Sources/MacTetherStore.swift
Patrick Ecord c9546ac222 core+apps: file transfer progress
Add a Transfer record {name, received, total, incoming, done} and an engine-side
transfers map updated by the RTC/LAN transports — fine-grained on receive (per
chunk in apply_file_frame), per-chunk on send. Engine.transfers() exposes
in-flight transfers (completed linger ~6s, then prune). Both apps poll it and
show a progress row with a bar + percent above the feed.

Verified with examples/progress_demo.rs: a 5MB file's transfer reports
received/total advancing to done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:57:43 -05:00

199 lines
7.7 KiB
Swift

import AppKit
import Foundation
import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clashing
// with the engine's Message (compiled into this target)
// Engine + Message + MessageHandler come from the generated tethercore.swift.
/// UI-facing feed item: a text clipboard, or a received file/image.
struct FeedItem: Identifiable {
let id = UUID()
let text: String // clipboard text, or the file name for files
let source: String
let date: Date
var image: NSImage? = nil // preview for image files
var fileURL: URL? = nil // saved location for any received file
var isFile: Bool { fileURL != nil }
}
/// macOS side: drives the shared `tethercore.Engine` (SSE + RTC). Unlike iOS we
/// CAN poll NSPasteboard in the background, so clipboard changes auto-send.
@MainActor @Observable
final class MacTetherStore {
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io"
// Prefilled shared identity for now it's just us, so all devices pair.
var account: String = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
// Room follows the account all our devices share one room everywhere.
var room: String = {
let a = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
return a.isEmpty ? RoomIdentity.derive() : roomForAccount(account: a)
}()
var feed: [FeedItem] = []
var nearby: [Peer] = [] // tether devices on the LAN, for the picker
var present: [Peer] = [] // devices online in the room ("who's here")
var receipts: [Receipt] = [] // delivery acks "seen by <device>"
var transfers: [Transfer] = [] // in-flight file transfers
var connected = false
var lastError: String?
var hasAttemptedConnect = false
private let deviceID = Host.current().localizedName ?? UUID().uuidString
private var engine: Engine?
private var bridge: EngineBridge?
private var pollTask: Task<Void, Never>?
private var nearbyTask: Task<Void, Never>?
private var lastChangeCount = NSPasteboard.general.changeCount
func connectIfSaved() {
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
connect()
}
func connect() {
let url = serverURL.trimmingCharacters(in: .whitespaces)
guard !url.isEmpty else {
hasAttemptedConnect = true
lastError = "Invalid server URL."
return
}
hasAttemptedConnect = true
UserDefaults.standard.set(serverURL, forKey: "serverURL")
UserDefaults.standard.set(account, forKey: "account")
disconnect()
let b = EngineBridge(store: self)
let e = Engine(server: url, room: room, from: deviceID, source: "macos", name: deviceID, account: account)
e.start(handler: b)
engine = e
bridge = b
connected = true
lastError = nil
// Poll the engine's mDNS-discovered device list for the picker.
nearbyTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name }
self.present = engine.present().sorted { $0.name < $1.name }
self.receipts = engine.receipts()
self.transfers = engine.transfers()
}
}
// Send: poll NSPasteboard for local changes push to room.
lastChangeCount = NSPasteboard.general.changeCount
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
guard let self else { return }
let count = NSPasteboard.general.changeCount
guard count != self.lastChangeCount else { continue }
self.lastChangeCount = count
if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty {
self.engine?.send(text: text)
}
}
}
}
func disconnect() {
engine?.stop()
engine = nil
bridge = nil
pollTask?.cancel()
pollTask = nil
nearbyTask?.cancel()
nearbyTask = nil
nearby = []
connected = false
}
/// Join a discovered device's room the "pair" action from the picker.
func pair(with peer: Peer) {
room = peer.room
connect()
}
func send(_ text: String) {
engine?.send(text: text)
}
/// Pick a file and send it P2P.
func attachFile() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url,
let data = try? Data(contentsOf: url) else { return }
engine?.sendFile(name: url.lastPathComponent, mime: Self.mime(for: url.pathExtension), data: data)
}
static func mime(for ext: String) -> String {
switch ext.lowercased() {
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "heic": return "image/heic"
case "pdf": return "application/pdf"
case "txt": return "text/plain"
default: return "application/octet-stream"
}
}
/// A received file: save to Downloads, preview if it's an image.
fileprivate func ingestFile(name: String, mime: String, data: Data, date: Date) {
let image: NSImage? = mime.hasPrefix("image/") ? NSImage(data: data) : nil
var url: URL?
if let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
let dest = downloads.appendingPathComponent(name.isEmpty ? "tether-file" : name)
try? data.write(to: dest)
url = dest
}
feed.insert(FeedItem(text: name, source: "file", date: date, image: image, fileURL: url), at: 0)
if feed.count > 100 { feed.removeLast() }
}
func copyToPasteboard(_ item: FeedItem) {
writePasteboard(item.text)
}
private func writePasteboard(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
lastChangeCount = NSPasteboard.general.changeCount // don't echo our own write back
}
// Called from the engine bridge, already on the main actor.
fileprivate func ingest(_ item: FeedItem) {
feed.insert(item, at: 0)
if feed.count > 100 { feed.removeLast() }
writePasteboard(item.text) // mirror received clipboard onto this Mac
}
fileprivate func setConnected(_ value: Bool) {
connected = value
}
}
/// Adapts the engine's callback interface (fired on the Rust runtime thread)
/// onto the main actor for SwiftUI.
private final class EngineBridge: MessageHandler {
weak var store: MacTetherStore?
init(store: MacTetherStore) { self.store = store }
func onMessage(msg: Message) {
// RTC-delivered messages carry ts=0 (no server stamp) use now.
let date = msg.ts > 0 ? Date(timeIntervalSince1970: Double(msg.ts) / 1000.0) : Date()
let item = FeedItem(text: msg.text, source: msg.source.isEmpty ? "unknown" : msg.source, date: date)
Task { @MainActor in self.store?.ingest(item) }
}
func onStatus(connected: Bool) {
Task { @MainActor in self.store?.setConnected(connected) }
}
func onFile(name: String, mime: String, data: Data) {
Task { @MainActor in self.store?.ingestFile(name: name, mime: mime, data: data, date: Date()) }
}
}