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:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user