macos: file/image UI — inline preview, attach to send, reveal in Finder

Wire on_file into the Mac app: received files save to ~/Downloads; images show
an inline preview in the feed, other files show a doc chip with a reveal-in-
Finder button. A paperclip in the composer opens a file picker → send_file
(mime inferred from extension). FeedItem gains optional image/fileURL.

Add `tether-agent send-file <path>` (one-shot share / test helper).

Verified live: an agent sent a 36KB PNG over RTC and it rendered inline in the
Mac app's feed.

Follow-up: iOS file UI (mirror), clipboard-image auto-send, transfer progress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 12:13:42 -05:00
parent d3dd785921
commit b2a1e06f56
3 changed files with 104 additions and 12 deletions

View File

@@ -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 <path>` — 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 <path> [--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);

View File

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

View File

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