Drop TetherKit's TetherClient/SSEClient/Message from the macOS app; drive tethercore.Engine instead (RoomIdentity still from TetherKit via selective import). The Mac now gets direct P2P over the RTC data channel for free, and the native Swift app is no longer a parallel wire-protocol implementation. Background NSPasteboard polling (auto-send on copy) and write-on-receive are preserved. Feed UI moves to a FeedItem view-model (RTC messages carry ts=0 → fall back to local time). Verified end-to-end against the live server: RECEIVE (bus → Mac pasteboard via pbpaste) and SEND (pbcopy → bus) both confirmed. Generalize core/build-ios.sh → build-apple.sh: builds iOS device + sim + macOS arm64 into one TetherCore.xcframework and vendors it into both apps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
4.3 KiB
Swift
134 lines
4.3 KiB
Swift
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@Bindable var store: MacTetherStore
|
|
@State private var draft = ""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
toolbar
|
|
Divider()
|
|
feedArea
|
|
Divider()
|
|
composer
|
|
}
|
|
.frame(minWidth: 480, minHeight: 400)
|
|
.task { store.connectIfSaved() }
|
|
}
|
|
|
|
// MARK: - Toolbar
|
|
|
|
private var toolbar: some View {
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(store.connected ? Color.green : Color.secondary.opacity(0.5))
|
|
.frame(width: 10, height: 10)
|
|
.animation(.easeInOut, value: store.connected)
|
|
|
|
if store.connected {
|
|
Text("Room \(store.room)").font(.callout).foregroundStyle(.secondary)
|
|
} else {
|
|
TextField("Server URL", text: $store.serverURL)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: 240)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if let err = store.lastError, store.hasAttemptedConnect {
|
|
Text(err).font(.caption).foregroundStyle(.red)
|
|
}
|
|
|
|
Button(store.connected ? "Disconnect" : "Connect") {
|
|
store.connected ? store.disconnect() : store.connect()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(store.connected ? .secondary : .accentColor)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
// MARK: - Feed
|
|
|
|
private var feedArea: some View {
|
|
List(store.feed) { msg in
|
|
feedRow(msg)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
|
|
}
|
|
.listStyle(.plain)
|
|
.overlay {
|
|
if store.feed.isEmpty {
|
|
ContentUnavailableView(
|
|
"No messages yet",
|
|
systemImage: "link.circle",
|
|
description: Text(store.connected
|
|
? "Copy anything on a connected device — it'll appear here."
|
|
: "Connect to your tether server to start syncing.")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
HStack {
|
|
Label(item.source, systemImage: sourceIcon(item.source))
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(item.date, style: .time)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Button {
|
|
store.copyToPasteboard(item)
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Copy to clipboard")
|
|
}
|
|
.padding(10)
|
|
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
private func sourceIcon(_ source: String) -> String {
|
|
let s = source
|
|
if s.contains("iphone") || s.contains("ios") { return "iphone" }
|
|
if s.contains("ipad") { return "ipad" }
|
|
if s.contains("macos") || s.contains("mac") { return "laptopcomputer" }
|
|
if s.contains("windows") { return "pc" }
|
|
return "desktopcomputer"
|
|
}
|
|
|
|
// MARK: - Composer
|
|
|
|
private var composer: some View {
|
|
HStack(spacing: 10) {
|
|
TextField("Type a message…", text: $draft, axis: .vertical)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(1...4)
|
|
.onSubmit {
|
|
guard !draft.isEmpty else { return }
|
|
store.send(draft); draft = ""
|
|
}
|
|
Button {
|
|
store.send(draft); draft = ""
|
|
} label: {
|
|
Image(systemName: "paperplane.fill")
|
|
}
|
|
.keyboardShortcut(.return, modifiers: .command)
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!store.connected || draft.isEmpty)
|
|
}
|
|
.padding(12)
|
|
}
|
|
}
|