diff --git a/agent/src/main.rs b/agent/src/main.rs index 341aefe..0a2cad6 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -96,11 +96,43 @@ fn main() { Some("install") => install(), Some("uninstall") => uninstall(), Some("status") => status(), + Some("send-file") => send_file_once(), // No subcommand (or --flags) → run in the foreground. _ => run(), } } +/// `tether-agent send-file ` — connect, wait for a direct link, send the +/// file, exit. Handy for testing / one-shot sharing. +fn send_file_once() { + let path = std::env::args() + .nth(2) + .expect("usage: tether-agent send-file [--server URL --account ID]"); + let data = std::fs::read(&path).expect("read file"); + let fname = std::path::Path::new(&path) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".into()); + let mime = match path.rsplit('.').next().map(str::to_lowercase).as_deref() { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("pdf") => "application/pdf", + _ => "application/octet-stream", + } + .to_string(); + + let c = config(); + let engine = Engine::new(c.server, c.room, c.from, c.source, c.name, c.account); + engine.start(Box::new(Inbox(Arc::new(Mutex::new(Vec::new()))))); + eprintln!("connecting + waiting for a direct link…"); + sleep(Duration::from_secs(10)); + eprintln!("sending {fname} ({} bytes, {mime})…", data.len()); + engine.send_file(fname, mime, data); + sleep(Duration::from_secs(5)); // let chunks flush + eprintln!("done"); +} + fn run() { let c = config(); eprintln!("tether-agent → {} room={} source={}", c.server, c.room, c.source); diff --git a/ios/MacTetherApp/Sources/ContentView.swift b/ios/MacTetherApp/Sources/ContentView.swift index 1a9d37b..28785cb 100644 --- a/ios/MacTetherApp/Sources/ContentView.swift +++ b/ios/MacTetherApp/Sources/ContentView.swift @@ -150,12 +150,21 @@ struct ContentView: View { private func feedRow(_ item: FeedItem) -> some View { HStack(alignment: .top, spacing: 10) { VStack(alignment: .leading, spacing: 4) { - Text(item.text) - .font(.body) - .lineLimit(6) - .textSelection(.enabled) + if let image = item.image { + Image(nsImage: image) + .resizable().scaledToFit() + .frame(maxWidth: 280, maxHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else if item.isFile { + Label(item.text, systemImage: "doc.fill") + .font(.body).lineLimit(1) + } else { + Text(item.text) + .font(.body).lineLimit(6).textSelection(.enabled) + } HStack { - Label(item.source, systemImage: sourceIcon(item.source)) + Label(item.isFile ? "file" : item.source, + systemImage: item.isFile ? "arrow.down.circle" : sourceIcon(item.source)) .font(.caption).foregroundStyle(.secondary) Spacer() Text(item.date, style: .time) @@ -163,13 +172,17 @@ struct ContentView: View { } } Button { - store.copyToPasteboard(item) + if let url = item.fileURL { + NSWorkspace.shared.activateFileViewerSelecting([url]) // reveal in Finder + } else { + store.copyToPasteboard(item) + } } label: { - Image(systemName: "doc.on.doc") + Image(systemName: item.isFile ? "magnifyingglass" : "doc.on.doc") .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Copy to clipboard") + .help(item.isFile ? "Reveal in Finder" : "Copy to clipboard") } .padding(10) .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) @@ -188,6 +201,15 @@ struct ContentView: View { private var composer: some View { HStack(spacing: 10) { + Button { + store.attachFile() + } label: { + Image(systemName: "paperclip") + } + .buttonStyle(.plain) + .disabled(!store.connected) + .help("Send a file or image") + TextField("Type a message…", text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...4) diff --git a/ios/MacTetherApp/Sources/MacTetherStore.swift b/ios/MacTetherApp/Sources/MacTetherStore.swift index ae317f8..6963dd2 100644 --- a/ios/MacTetherApp/Sources/MacTetherStore.swift +++ b/ios/MacTetherApp/Sources/MacTetherStore.swift @@ -4,12 +4,15 @@ import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clas // with the engine's Message (compiled into this target) // Engine + Message + MessageHandler come from the generated tethercore.swift. -/// UI-facing clipboard item. Decouples the view from the FFI `Message` record. -struct FeedItem: Identifiable, Equatable { +/// UI-facing feed item: a text clipboard, or a received file/image. +struct FeedItem: Identifiable { let id = UUID() - let text: String + 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 @@ -113,6 +116,41 @@ final class MacTetherStore { 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) } @@ -153,6 +191,6 @@ private final class EngineBridge: MessageHandler { } func onFile(name: String, mime: String, data: Data) { - // File receive UI is a follow-up; engine handles transport + reassembly. + Task { @MainActor in self.store?.ingestFile(name: name, mime: mime, data: data, date: Date()) } } }