Commit Graph

46 Commits

Author SHA1 Message Date
fdbc99d40e agent: install/uninstall as a login service (launchd / systemd)
Add subcommands so the desktop agent runs in the background on login — the real
desktop product shape, no window. `tether-agent install` writes a launchd
LaunchAgent (macOS) or systemd user unit (Linux) with the resolved config baked
in (run mode + server/account/name/room), loads it, and it survives reboots;
`uninstall`/`status` manage it. Foreground `run` unchanged (the no-subcommand
default). Windows falls through with a hint.

Verified on macOS: install loads the agent (launchctl lists it, process runs
with the baked args), uninstall removes it cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:40:41 -05:00
88ae0e6958 presence: "who's here" — online devices in the room, with names
Presence chirps now carry the friendly device name, and the engine tracks the
present set as Peers (id/name/source/room). New Engine.present() -> [Peer]
exposes who's online in the room across any network. Both apps poll it and show
a green-dot row of online devices above the feed.

The present set already powered prefer-direct's coverage check; this enriches it
with names and surfaces it. Verified live on macOS: an agent in the room shows
as "● Patrick's NAS".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 04:15:14 -05:00
ee938600c3 core: harden receipts — hash not text, and gate the ack on direct-coverage
Receipts carried the clipboard text and weren't coverage-gated, so an ack could
echo content over the relay even when the clipboard stayed P2P. Now the receipt
carries fingerprint(text) (sha256[..8]) instead of the text, and the auto-ack
skips the SSE relay when a direct path covers the room — same rule as send().
Closes the last way content could reach the server once P2P is up.

Verified: prefer_direct still keeps the payload off the bus (only presence
relayed, no receipts), and receipt_demo still confirms delivery (matched by
device name now that the text is hashed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:25:38 -05:00
7ccb32bb64 core: prefer-direct — suppress the relay once P2P covers the room
The engine sent clipboard over every transport at once, so the server always
saw the payload even with RTC/LAN up. Now the RTC and LAN transports report
their directly-reachable peers (data-channel open / TCP connected) into a shared
set, and the RTC transport records who's present from presence chirps. send()
skips the SSE relay when every present peer is directly reachable — so once P2P
is established the server only ever carries signaling/presence, never content.
Falls back to the relay whenever a present peer isn't directly reachable, or
before the direct link is up.

Also drop empty-text clipboards in the sink (were surfacing as blank "unknown"
feed rows).

Proven by examples/prefer_direct.rs: two engines link directly on the live
server; a send reaches the peer but never appears on the server bus (only
presence does).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:22:09 -05:00
cd0ac012e0 apps: show delivery receipts — "✓ Seen by <device>"
Both apps now poll engine.receipts() alongside nearby() and render a "Seen by
<names>" bar above the composer when acks come in. Closes the loop the receipt
engine opened: you can see your clipboard was received, instead of asking.

Verified live on macOS: copying on the Mac with a peer in the room shows
"✓ Seen by Rust LAN Peer".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:59:35 -05:00
cb0df2af81 ios: Nearby-devices picker (AirDrop-style), matching the Mac
Port the nearby picker to iOS: the store polls engine.nearby() while connected
and RoomView shows discovered devices as tap-to-join chips with platform icons,
same as MacTetherApp. pair() joins the device's room.

Dormant on iOS until the multicast entitlement lands (Rust mdns-sd uses a raw
multicast socket iOS blocks without it) — the UI is ready; it'll populate once
the entitlement is granted. No effect when the list is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:39:27 -05:00
290284f4e1 ios: auto-connect on launch + use https default placeholder
Add .task { store.connect() } so the app connects on open instead of requiring
a manual Connect tap (macOS already did this) — the defaults are production-ready
(public relay + account-derived room). This is also what surfaced the stale
"ttps://" typo bug: a corrupted saved server URL silently failed connect with no
visible error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:35:07 -05:00
1fbe989675 core: delivery receipts — "seen by <device>" instead of "did you see it?"
On every inbound clipboard the engine auto-sends a receipt back to the sender
(type:"receipt", to:sender, role:my-name, text:the-clipboard). Senders collect
them via Engine.receipts() -> [Receipt{from,name,source,text,ts}] so the UI can
show "delivered to iPhone" per sent item — visible read state, not manual asking.

Plumbing this required: SSE post now sends the full envelope (type/to/role), not
just text; the SSE stream forwards receipts as well as clipboards; RTC's raw-text
data channel skips non-clipboard messages (receipts ride SSE/LAN). Receipts
de-dupe across transports.

Proven by examples/receipt_demo.rs: A sends, B auto-acks, A.receipts() shows
"seen by Patrick's iPhone".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:34:45 -05:00
d1750a95d6 core: account-derived rooms — same account → same room everywhere
Add room_for_account(account) = sha256(account)[..4] hex, exported via UniFFI so
every client derives the IDENTICAL room from a shared identity. Clients now
default their room to the account-derived id (falling back to a device id only
when signed out). With the prefilled account, all our devices land in one room
across LAN, the relay, and RTC — so "just us" works cross-network, not only on
the same Wi-Fi. The single canonical Rust impl guarantees the ids match across
Swift/Kotlin/agent (a hand-rolled per-platform hash would risk drift).

Verified: agent and the live Mac app both derive room 360309c8 from
"pecord@gmail.com"; the Mac app publishes there on the public relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:55:28 -05:00
f7bc8eed4b clients: prefill a shared account so our devices auto-pair ("just us")
Default the account to a shared identity (overridable, persisted in
UserDefaults/prefs; agent via --account) instead of empty. With it prefilled,
every one of our devices on the same network auto-pairs over the LAN regardless
of room — no setup. Swapped in for the real Sign-in-with-Apple `sub` later.

Verified with the live Mac app: it and an agent in a totally different room
LAN-paired purely via the shared account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:49:35 -05:00
c3b9b7940f core: account identity — same-account devices auto-pair on the LAN
Add an `account` dimension (Apple/Google `sub`, or any shared id) advertised
over mDNS alongside the device name. The LAN transport now connects to a peer
when they share a room OR a non-empty account — so your own devices find each
other and sync on the network regardless of room, no pairing step. Peer gains
`account` so the UI can mark "your devices".

Engine::new gains `account` (6th arg, ""=signed out); all callers updated.
Apps pass "" until sign-in lands; agent takes --account.

Proven by examples/account_pair.rs: two devices in DIFFERENT rooms with the
same account sync over LAN with no server.

Next: derive the account from Sign in with Apple / Google so this is automatic
(the cross-network version = account-derived shared server room).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:41:00 -05:00
049456521f macos: Nearby-devices picker (AirDrop-style) over the LAN
Add a horizontal strip of discovered tether devices to the window: the store
polls engine.nearby() every 2s while connected and renders each as a chip with
its friendly name + platform icon. Tapping a device calls pair() — sets room to
that device's room and reconnects, so you join it without typing a room id.
Devices already in your room show "in your room" (disabled).

Verified live on macOS: two named LAN devices appear as "tap to join" chips
once the app has Local Network permission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:26:37 -05:00
6e24d19a58 core: nearby-device discovery (AirDrop-style) — friendly names + Engine.nearby()
Each device now advertises a friendly name over mDNS ("Patrick's MacBook"),
and the LAN transport records every tether device on the network — regardless
of room — into a discovered table. New Engine.nearby() -> [Peer] exposes that
list for an AirDrop-style picker; entries prune after 30s unseen. Peer carries
{id, name, source, room} so the UI can offer "pair" (join that device's room).

Engine::new gains a `name` param (5th); all callers updated (apps pass
UIDevice.name / Host.localizedName / Build.MODEL, agent passes hostname or
--name). Auto-connect stays room-scoped; only the *visibility* is broadened.

Proven by examples/nearby.rs: two engines in different rooms discover each
other by name over mDNS — and it picks up real devices on the LAN too.

iOS caveat unchanged: mDNS needs the multicast entitlement, so nearby() is
empty there until that lands. Desktop/Android work today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:09:56 -05:00
e52e20c2c5 clients: default server URL → https://tether.pecord.io
Replace the dead tether.lan / localhost / emulator-host placeholders with the
live public relay across all clients (iOS, macOS, Android, agent), so a fresh
install connects out of the box. All remain overridable (UI field / --server).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:01:58 -05:00
0e0b504808 core: RTC fetches server TURN creds from /api/turn-cred (cross-network P2P)
The RTC transport hardcoded Google STUN, so native clients never used the TURN
relay the homelab server already runs — meaning hole-punch failures (symmetric
NAT / CGNAT, i.e. cellular) had no E2E fallback. Now RtcTransport fetches
{server}/api/turn-cred at startup (same contract the web client uses) and feeds
the returned STUN+TURN iceServers into every RTCPeerConnection. Falls back to
STUN if the endpoint is absent (verified: local server 404s → STUN, delivery
still works via the other transports).

This is the client half of "cross-network P2P that just works" — the server
half (integrated pion TURN + /api/turn-cred) is already deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:54:38 -05:00
377aa67d4d core: quiet transport reconnect spam for LAN-only / dead-server mode
SSE and RTC logged an error (and SSE flipped status) on every reconnect
attempt, so running without a reachable server — the legit LAN-only case —
flooded the log and spammed on_status(false). Now each transport reports a
drop once per outage streak and resets on reconnect; SSE no longer logs
per-send failures (implied by the connection-state line).

Verified: serverless agent↔peer LAN sync still works both directions, with the
agent log down from ~25 lines to 7 (one disconnect notice per transport, then
real activity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:24:08 -05:00
f9a7f38bb7 core: lan_peer example — verify agent serverless sync without clipboard contention
A controllable LAN peer (dead server, doesn't touch the OS clipboard) used to
prove the desktop agent syncs over LAN alone. Confirmed both directions on macOS
with the server unreachable: pbcopy → agent → peer, and peer → agent → pasteboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:20:45 -05:00
09d63651ab core: LAN transport — serverless same-network sync over mDNS + TCP
Add a third transport behind the Transport trait: each engine advertises
_tether._tcp.local. (TXT room/from/source) and browses for peers in the same
room, then connects directly over plain TCP (lower `from` dials to avoid
double-connects) exchanging newline-delimited JSON Messages. No server, no
internet — the first truly serverless transport, complementing SSE (relay) and
RTC (P2P-after-signaling).

Engine dedup already collapses a clipboard arriving over LAN + SSE/RTC.

Proven by examples/lan_p2p.rs: with a DEAD server URL (SSE/RTC can't deliver),
two engines discover each other via mDNS and sync over TCP.

Scope/caveats: room id is the only access control and the link is plaintext
(rustls keyed on a room secret is the follow-up); iOS needs the multicast
entitlement for mDNS, so the transport is inert there until that lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:15:06 -05:00
38b9b55ac8 cleanup: retire dead TetherKit networking, fix iOS UI papercuts, modernize README
- Delete TetherKit's Message/SSEClient/TetherClient + MessageTests — all dead
  since both apps moved to tethercore. TetherKit is now just RoomIdentity; drop
  the empty test target and the now-unused `import TetherKit` from RoomView.
- iOS: Connect button mislabeled "Reconnect" while connected — now "Disconnect"
  and actually disconnects, matching the macOS app.
- iOS: feed timestamps for RTC-delivered messages (ts=0) fell back to 1970;
  use now, matching the macOS bridge.
- README: rewrite from the Go-only story to the shared-core architecture
  (tethercore engine + native clients on every platform) with a layout diagram.

All four targets rebuild clean (core, agent, iOS, macOS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:01:10 -05:00
3581c1e86c android: Compose client scaffold on tethercore (Kotlin/UniFFI)
Add an Android app that drives the same tethercore engine (SSE + RTC) as the
iOS/macOS apps via generated Kotlin/UniFFI bindings — proving the shared core
reaches a fourth language with no protocol reimplementation. Engine surface is
identical to Swift: Engine(server,room,from,source) / start / send / stop.

Includes the Gradle/Compose project, a TetherStore that bridges the engine to
ClipboardManager (auto-send via OnPrimaryClipChangedListener — Android allows
foreground clipboard observation, unlike iOS), per-install RoomIdentity, and
core/build-android.sh (cargo-ndk → jniLibs + Kotlin bindgen).

Not built here: this machine has no Android SDK/NDK, so the native .so and
Kotlin binding are generated (git-ignored), not committed. README documents the
NDK install + build steps. The binding itself was confirmed to generate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:42:36 -05:00
1caa44e253 agent: headless desktop clipboard agent on tethercore (macOS/Linux/Windows)
A single Rust binary: the shared tethercore engine (SSE + RTC) wired to the OS
clipboard via arboard. Inbound room clipboard → local clipboard; local changes
→ published to the room, with echo suppression. No UI — meant to run as a
login/service daemon. One codebase covers all three desktops.

  tether-agent --server http://host:8765 --room <id> [--source label]

Verified on macOS against the live server: RECEIVE (bus → clipboard via pbpaste)
and SEND (pbcopy → bus) both confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:38:24 -05:00
db64b3b9a6 macos: migrate app to the shared tethercore engine (SSE + RTC)
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>
2026-06-20 23:31:54 -05:00
fdd65eea7c core: in-core RTC transport — direct P2P clipboard over WebRTC data channel
Implement RtcTransport (webrtc-rs) behind the Transport trait, so every native
platform that links tethercore gets direct P2P alongside SSE from one impl.

Signaling rides the existing server bus and matches the web client byte-for-
byte (presence chirp, lower-id-offers tie-break, {kind, sdp|candidate} signal
envelopes, "tether" data channel carrying raw clipboard text) — so Rust peers
interoperate with the browser's native RTCPeerConnection.

Dedup now keys on (from, text) with a 3s TTL so the SSE and data-channel copies
of the same clipboard collapse to one; the faster RTC copy wins.

Proven by examples/rtc_p2p.rs: two engines bring up a data channel and a
clipboard is delivered over it (received source == peer from-id, confirming the
RTC path beat the SSE relay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:38:12 -05:00
a700a4b54b core: add webrtc-rs (validated host + iOS); SSE forwards clipboard only
Add the webrtc dependency that the in-core RTC transport will use. Verified
it cross-compiles to aarch64-apple-ios, confirming RTC can live in the shared
core and reach every native platform from one implementation (the browser
keeps native RTCPeerConnection and interops over the wire).

Also fix a latent bug: the SSE transport forwarded every message type to the
sink, so a peer's presence/signal chirps would leak into the clipboard feed.
Now SSE forwards only clipboard; presence/signal belong to the RTC layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:33:22 -05:00
19f5eaccdf core: make the engine multi-transport (Transport trait + inbound dedup)
Restructure tethercore so the engine owns a list of pluggable Transports
instead of a hardcoded SSE path. SSE moves behind the trait unchanged;
RTC (webrtc-rs data channel) and BT (BLE GATT) now have a clean socket to
implement the same interface — start(sink, status) / publish(msg).

The engine multiplexes outbound across all transports and de-dups inbound
(keyed on from+ts+text) so a clipboard echoed over two channels surfaces
once. No-op with SSE alone; required the moment RTC lands.

FFI surface is unchanged (Engine::new / start / send / stop, Message,
MessageHandler) — no binding regen needed. Round-trip example still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:48:01 -05:00
2c375632a5 core: shared Rust sync engine (tethercore) + wire into iOS via UniFFI
Introduce core/ — a Rust crate owning the wire protocol (Message codec,
SSE client, reconnect/backoff, send) behind a 4-method seam exported via
UniFFI: Engine::new / start(handler) / send / stop. This is the single
sync engine meant to back all clients; desktop links it directly, iOS
binds an .xcframework, Android will bind an .aar. RTC mesh + the signal
envelope are deliberately out of scope for v1 (SSE-only).

Verified end-to-end against the live server: examples/roundtrip.rs has a
subscriber engine receive a message published by a sender engine.

iOS now links it: TetherStore drops TetherKit's TetherClient/SSEClient/
Message and drives tethercore.Engine instead (RoomIdentity still from
TetherKit via selective import to avoid the Message name clash). The app
builds and links the Rust staticlib for device.

Artifacts (xcframework, Swift bindings) are generated by core/build-ios.sh
and git-ignored; commit source + script, not the ~80MB static libs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:44:58 -05:00
b39fcc37e8 ios: fix on-device launch crash; harden room identity
Bump iOS deployment target 17→26: the OS_dispatch_mach_msg
"_setContext: unrecognized selector" launch crash on the iPhone 17 Pro
was a libdispatch/concurrency-runtime mismatch between the iOS 17 target
and the iOS 26 device. App now launches and stays resident on device.

RoomIdentity: drop the iCloud ubiquityIdentityToken + NSKeyedArchiver
path (a second suspected crash vector) in favor of IOKit hardware UUID
on macOS and a Keychain-stored 8-hex UUID on iOS. SiwA/iCloud identity
returns at v0.6 once dev-program entitlements land.

Also add ios/.gitignore and untrack .build/ artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:12:28 -05:00
c73108a126 Add macOS window UI (ContentView) alongside menu bar
WindowGroup with resizable window (560×520 default): connection toolbar,
feed list with source icons + text selection + copy button, composer with
Cmd+Return shortcut. Both window and menu bar share the same store.
Removed LSUIElement — app now shows in Dock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:27:55 -05:00
c4334143b0 Auto-derive room ID from iCloud account or device hardware
RoomIdentity.derive() in TetherKit: ubiquityIdentityToken (iCloud account,
portable across devices) → IOKit/identifierForVendor (hardware, device-tied)
→ Keychain UUID (last resort). No user config needed. Both apps auto-connect
on relaunch if server URL was previously saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:00:12 -05:00
6c2dcdd603 Add macOS menu bar app (MacTetherApp)
MenuBarExtra SwiftUI app sharing TetherKit wire layer with iOS.
Auto-sends clipboard changes via NSPasteboard polling (500ms),
auto-writes received messages to NSPasteboard. XcodeGen project.yml.
Both apps build clean; server tested end-to-end with curl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:27:21 -05:00
14b6be7548 Add iOS v1 client: TetherKit SPM + SwiftUI app
TetherKit: Codable Message envelope (1:1 with Go server), SSE reader over
URLSession.bytes, TetherClient with reconnect/backoff. 3 wire-format tests pass.
TetherApp: SwiftUI room UI (connect, live feed, send clipboard/text, tap-to-copy),
generated via XcodeGen (project.yml). SSE-only, foreground; no WebRTC/APNs yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:11:31 -05:00
Claude Opus 4.7
a0f7498663 web: deterministic browser-to-browser pairing via presence
Browsers were always answerers — meant two browsers in the same room
(no Go peer present) would both wait forever for an offer that no one
would send. Now: on presence from another peer, the browser with the
smaller peerID (lexicographic) initiates the offer; the other side
answers. Tie-break in handleOffer handles the race where both posted
presence simultaneously and could double-offer.

This makes pure-browser meshes work — phone + laptop in the same room
auto-pair without needing a Go peer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:24:23 -05:00
Claude Opus 4.7
099c66ab5a web: focus textarea on paste + better fallback for iOS clipboard
Old code immediately showed 'clipboard read denied' if navigator.clipboard
.readText() rejected, even when iOS Safari was still showing the Paste
button. New behavior: focus the textarea first so the user has an
unambiguous target for long-press-paste, then try readText, then
execCommand('paste') as belt-and-suspenders. Only show the denial
message if all three failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:20:44 -05:00
Claude Opus 4.7
625143f87a ua-labels: derive source from User-Agent, persist peerID in localStorage
Server now parses the User-Agent header on /api/send when no explicit
source is provided, producing labels like 'iphone-safari',
'macos-chrome', 'windows-firefox' so the feed shows where messages
came from at a glance.

Browser-side peerID is now stored in localStorage instead of being
freshly random on each page load — refreshing or reopening the tab
keeps the same identity for the same browser profile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:19:14 -05:00
Claude Opus 4.7
618d330682 v0.5: stateless room-based sessions with QR pairing
The single shared mesh is replaced by per-session rooms. Visit /
and the server mints a random 8-hex-char id, redirects to /r/<id>.
That URL IS the session — share the link (or scan the QR code now
shown on the page) on another device to join the same room.

Bus is now sharded per room. Rooms are created implicitly on first
subscribe and GC'd 5 minutes after the last subscriber leaves. No
accounts, no persistence, no server-side state beyond the in-memory
bus map.

Server:
- New endpoints: /, /r/<id>, /api/send?room=, /api/stream?room=
- Room manager with lazy creation + idle GC
- Metrics now labelled by room
- New gauge tether_active_rooms

Client (Go):
- -room flag (accepts bare id OR full /r/<id> URL — paste-friendly)
- All API calls now scope to the room
- The always-on ct210-rtc-peer systemd unit is disabled — sessions
  are user-initiated; the user runs tether-client with -room when
  they want their laptop in a particular session

Browser (HTML):
- Reads room from /r/<id> path
- Shows QR code + URL + "copy link" button at top
- "+ new session" link in header to start a fresh room

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:13:33 -05:00
Claude Opus 4.7
7995908c87 v0.4: symmetric presence chirps + per-peer mesh
Both Go peer and browser now broadcast {type:"presence", from, role}
every 10s on the bus. When either side sees a presence from someone
they don't yet have a RTCPeerConnection to, they initiate a new one
targeted at that specific peerID via the new "to" field on signal
messages. Each side keeps a map<peerID, RTCPeerConnection> instead of
the v0.3 single-connection model.

This means:
- N browsers can pair with M peers (true mesh)
- New tabs auto-discover existing peers via their next 10s chirp
- Restarts and network blips recover within 10s instead of needing
  a manual browser refresh
- 45s lastSeen timeout sweeps disconnected peers and tears down their
  PeerConnection

The browser UI now shows a row of peer chips that flip green when their
DataChannel opens. The pill shows "rtc" if *any* peer is open, else
"negotiating" if any are in progress, else "sse".

Go side regenerates a random peerID per process start (was static).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:09:24 -05:00
Claude Opus 4.7
6ea7ed579e README: document v0.4 symmetric presence chirp design + bump status
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:05:50 -05:00
Claude Opus 4.7
7e63ffd357 client: chirp offer every 5s while unpaired
Solves the late-subscriber problem — browsers that load the page after
the peer's startup offer would never see one. Now the peer re-broadcasts
the offer every 5 seconds until the DataChannel opens, then stops.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:03:06 -05:00
Claude Opus 4.7
ce020d641a web: stop double-JSON-encoding the signal payload
Browser was JSON.stringify-ing the inner payload before putting it
in the outer message envelope. Go side then saw a JSON string where
it expected a JSON object, and SignalPayload Unmarshal silently
failed — which is why answers never made it back through the
peer connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:56:38 -05:00
Claude Opus 4.7
09355e9914 web: filter incoming by per-browser peerID, not source label
Old filter hid all source=web messages — meant multiple browsers
couldn't see each other's sends. Now each browser only filters out
its OWN peerID, so iphone↔mbp↔desktop all see each other's clipboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:48:21 -05:00
Claude Opus 4.7
137a81c6a8 label: source 'phone' → 'web' (page works from any browser)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:42:42 -05:00
Claude Opus 4.7
98dc2ca2a6 v0.3: WebRTC P2P via Pion (Go) + RTCPeerConnection (browser)
Server: unchanged shape, just added a "signal" message type to the
existing /api/send + /api/stream bus. Now carries both "clipboard"
(payload) and "signal" (offer/answer/ICE) over the same envelope.

Client: -rtc flag turns the Go listener into a Pion peer. Posts an SDP
offer at startup, accepts the browser's answer through the signaling
bus, exchanges ICE, then receives clipboard text over a DataChannel
named "tether". On message: writes to OS clipboard same as SSE path.

Web UI: acts as the answerer. Listens for "signal" SSE events, replies
to offers, exchanges ICE. When DataChannel opens, the send button uses
RTCDataChannel.send() instead of POST /api/send — data no longer
traverses the server after pairing. Pill in the header flips
sse → negotiating → rtc to make this visible.

Toolchain: bumped go.mod to go 1.26, switched to pion/webrtc v4 and
prometheus/client_golang v1.23.x.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:37:31 -05:00
Claude Opus 4.7
80539ae60c server: /metrics endpoint + signaling stub for v0.3 WebRTC
- Add Prometheus counters/gauges/histograms: messages_total{source},
  message_bytes_total, active_subscribers, publish_duration_seconds.
- Add /api/signal/<room> mailbox endpoint (POST adds, GET drains).
  Currently scaffolding for WebRTC SDP/ICE exchange — peers do not
  use it yet; client-side WebRTC negotiation is roadmap.

client: structured default label

Use "<GOOS>-sse-<role>" (e.g., windows-sse-listener) instead of the
old "linux-client" fallback. -label still overrides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:30:37 -05:00
Claude Opus 4.7
24854e44d6 client: default to https://tether.pecord.io for double-click UX
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:22:31 -05:00
Claude Opus 4.7
fa52bf2693 client: actually write incoming messages to the OS clipboard
The MVP only printed to stdout. Now the listener calls
clipboard.WriteAll on every received message, except when the message
originated from itself (to avoid clobbering local edits with our own
prior send).

Adds:
- github.com/atotto/clipboard (cross-platform: Win/macOS/Linux)
- -no-clipboard flag for stdout-only mode
- "→ clipboard updated" trace line so the user can confirm the write

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:19:35 -05:00
Claude Opus 4.7
b8f168df54 v0.1: HTTP+SSE broadcast bus + phone web UI + Linux client
Single Go module with two binaries (server, client) and an embedded
phone UI. MVP transport is HTTP POST → SSE fanout; the roadmap calls
for upgrading to WebRTC P2P with Sign in with Apple for identity, mDNS
for discovery, and OS clipboard hooks.

- server/: Go HTTP server, embedded index.html, broadcast bus with
  short replay history, SSE stream endpoint, single-binary deploy.
- client/: subscribes to SSE feed and prints messages; -send for
  one-shot publish from CLI. No OS clipboard touched yet (v0.5).
- web/index.html: dark phone-first UI, paste-clipboard button (uses
  navigator.clipboard.readText), live feed of incoming messages via
  EventSource.

This commit is intentionally tiny — it proves the end-to-end shape so
the WebRTC/SiwA/mDNS pieces can be added incrementally without
restructuring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 23:53:31 -05:00