Wire on_file into the iOS app: received files save to Documents; images render inline in the feed, other files show a doc chip. A paperclip opens a file importer → send_file. FeedItem gains optional image/fileURL, matching macOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
7.0 KiB
Swift
185 lines
7.0 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import UIKit
|
|
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,
|
|
// built directly into this module — no `import` needed.
|
|
|
|
/// UI-facing clipboard item. Decouples the view from the FFI `Message` record
|
|
/// and supplies the `id`/`date` SwiftUI's List needs.
|
|
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: UIImage? = nil // preview for image files
|
|
var fileURL: URL? = nil // saved location for any received file
|
|
var isFile: Bool { fileURL != nil }
|
|
}
|
|
|
|
/// Observable bridge between the shared `tethercore.Engine` and SwiftUI.
|
|
/// The engine owns the wire protocol; this owns the clipboard side-effects.
|
|
@MainActor @Observable
|
|
final class TetherStore {
|
|
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.
|
|
// Becomes the Sign-in-with-Apple `sub` once the dev program clears.
|
|
var account: String = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
|
|
// Room follows the account, so all our devices share one room everywhere
|
|
// (LAN, relay, RTC). Falls back to a device id only when signed out.
|
|
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 (empty on iOS w/o multicast entitlement)
|
|
var present: [Peer] = [] // devices online in the room ("who's here")
|
|
var receipts: [Receipt] = [] // delivery acks — "seen by <device>"
|
|
var connected = false
|
|
var lastError: String?
|
|
|
|
private let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
|
private var engine: Engine?
|
|
private var bridge: EngineBridge?
|
|
private var nearbyTask: Task<Void, Never>?
|
|
|
|
func connectIfSaved() {
|
|
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
|
|
connect()
|
|
}
|
|
|
|
func connect() {
|
|
let url = serverURL.trimmingCharacters(in: .whitespaces)
|
|
guard !url.isEmpty, !room.isEmpty else {
|
|
lastError = "Set a server URL first."
|
|
return
|
|
}
|
|
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: "ios", name: UIDevice.current.name, account: account)
|
|
e.start(handler: b)
|
|
engine = e
|
|
bridge = b
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
engine?.stop()
|
|
engine = nil
|
|
bridge = 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()
|
|
}
|
|
|
|
/// Read the system pasteboard (foreground only — iOS bans background reads)
|
|
/// and push it to the room.
|
|
func sendClipboard() {
|
|
guard let text = UIPasteboard.general.string, !text.isEmpty else {
|
|
lastError = "Clipboard is empty."
|
|
return
|
|
}
|
|
send(text)
|
|
}
|
|
|
|
func send(_ text: String) {
|
|
engine?.send(text: text)
|
|
}
|
|
|
|
/// Send a picked file P2P.
|
|
func sendFile(at url: URL) {
|
|
let scoped = url.startAccessingSecurityScopedResource()
|
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
|
guard 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 Documents, preview if it's an image.
|
|
fileprivate func ingestFile(name: String, mime: String, data: Data, date: Date) {
|
|
let image: UIImage? = mime.hasPrefix("image/") ? UIImage(data: data) : nil
|
|
var url: URL?
|
|
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
let dest = docs.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() }
|
|
}
|
|
|
|
/// Copy a received message back onto the local pasteboard.
|
|
func copyToPasteboard(_ item: FeedItem) {
|
|
UIPasteboard.general.string = item.text
|
|
}
|
|
|
|
// Called from the engine bridge, already hopped onto the main actor.
|
|
fileprivate func ingest(_ item: FeedItem) {
|
|
feed.insert(item, at: 0)
|
|
if feed.count > 100 { feed.removeLast() }
|
|
}
|
|
|
|
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: TetherStore?
|
|
init(store: TetherStore) { 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()) }
|
|
}
|
|
}
|