diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a94320c..47e55c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,6 +107,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.tag }} - name: Mark as pre-release if specified diff --git a/.goreleaser.yaml b/.goreleaser.yaml index eb8da1e..28d8521 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -63,6 +63,20 @@ changelog: - title: Others order: 999 +brews: + - repository: + owner: gomantics + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + directory: Formula + homepage: "https://github.com/gomantics/clipx" + description: "LAN clipboard sync for macOS. Copy on one Mac, paste on another." + license: "MIT" + install: | + bin.install "clipx" + test: | + system "#{bin}/clipx", "version" + release: github: owner: gomantics @@ -76,7 +90,10 @@ release: ### Installation ```bash - # Using Go + # Homebrew + brew install gomantics/tap/clipx + + # Go go install github.com/gomantics/clipx/cmd/clipx@{{ .Tag }} # Or download the binary from the assets above diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..08cb851 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to clipx + +Thanks for your interest in contributing! Here's how to get started. + +## Development + +### Prerequisites + +- Go 1.25+ (or use [mise](https://mise.jdx.dev/) — the repo includes a `.mise/config.toml`) +- macOS (clipx uses `pbcopy`/`pbpaste`) + +### Build & test + +```bash +make build # build binary to ./clipx-bin +make test # run tests +make test-race # run tests with race detector +make check # fmt + vet + test +make coverage # generate HTML coverage report +``` + +### Project structure + +``` +cmd/clipx/ CLI entry point (commands, LaunchAgent management) +clipx/ + clipboard.go Clipboard abstraction (pbcopy/pbpaste) + config.go Config file (~/.config/clipx/config.json) + net.go Network utilities (DNS resolution, ping) + node.go Core daemon (listener, clipboard watcher, peer sync) + protocol.go Wire protocol (encode/decode messages & chunks) + *_test.go Tests +``` + +### Running locally + +```bash +# terminal 1 — start a node +make build && ./clipx-bin + +# terminal 2 — pair and test +./clipx-bin pair 127.0.0.1 +``` + +## Submitting changes + +1. Fork the repo +2. Create a feature branch (`git checkout -b my-feature`) +3. Make your changes +4. Run `make check` to ensure everything passes +5. Commit with a [conventional commit](https://www.conventionalcommits.org/) message +6. Open a pull request + +## Commit messages + +We use [conventional commits](https://www.conventionalcommits.org/): + +- `feat:` new features +- `fix:` bug fixes +- `perf:` performance improvements +- `docs:` documentation changes +- `test:` test changes +- `chore:` maintenance tasks + +## Reporting issues + +Please open a [GitHub issue](https://github.com/gomantics/clipx/issues) with: + +- Your macOS version +- `clipx version` output +- Steps to reproduce +- Relevant logs from `~/Library/Logs/clipx.log` + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/README.md b/README.md index fe7bbff..5fa668a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # clipx +[![CI](https://github.com/gomantics/clipx/actions/workflows/ci.yml/badge.svg)](https://github.com/gomantics/clipx/actions/workflows/ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/gomantics/clipx)](https://goreportcard.com/report/github.com/gomantics/clipx) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Release](https://img.shields.io/github/v/release/gomantics/clipx)](https://github.com/gomantics/clipx/releases/latest) + LAN clipboard sync for macOS. Copy on one Mac, paste on another. Instantly. No cloud. No account. No Apple ID. No flaky Universal Clipboard. @@ -19,17 +24,23 @@ Each Mac runs `clipx`. When you copy something, it sends the clipboard content d - **UDP unicast** — reliable, fast, no firewall issues with multicast - **Explicit pairing** — `clipx pair `, no flaky auto-discovery - **SHA-256 dedup** — prevents infinite ping-pong loops between nodes -- **10MB max** — large content is automatically chunked into 16KB UDP packets +- **10 MB max** — large content is automatically chunked into ~1300-byte UDP packets -## Setup +## Install -### 1. Install on both Macs +### Homebrew (recommended) + +```bash +brew install gomantics/tap/clipx +``` + +### Go install ```bash go install github.com/gomantics/clipx/cmd/clipx@latest ``` -Or from source: +### From source ```bash git clone https://github.com/gomantics/clipx.git @@ -37,7 +48,13 @@ cd clipx make build # binary at ./clipx-bin ``` -### 2. Pair them +### Download binary + +Pre-built binaries for macOS (Intel & Apple Silicon) are available on the [releases page](https://github.com/gomantics/clipx/releases/latest). + +## Quick start + +### 1. Pair your Macs On **Mac A** (e.g. 192.168.0.5): @@ -51,7 +68,7 @@ On **Mac B** (e.g. 192.168.0.6): clipx pair 192.168.0.5 # IP of Mac A ``` -### 3. Install and run +### 2. Install and run On **both Macs**: @@ -144,11 +161,12 @@ One port, UDP only. If you run a firewall, `clipx install` handles it automatica ### Protocol -All communication is UDP unicast on port 9877. Three message types: +All communication is UDP unicast on port 9877. Four message types: | Type | Byte | Purpose | |---|---|---| -| Clipboard | `C` | Carries clipboard content to peers | +| Clipboard | `C` | Carries clipboard content (≤1300 bytes) | +| Chunk | `K` | Carries a chunk of large clipboard content | | Ping | `P` | Health check request | | Pong | `A` | Health check response | @@ -156,17 +174,26 @@ Wire format: `[6B magic "CLIPX2"] [1B type] [8B nodeID] [payload]` Clipboard payload: `[64B SHA-256 hex hash] [clipboard data]` +Chunk payload: `[64B SHA-256 hex hash] [2B chunk index] [2B total chunks] [chunk data]` + ### Loop prevention 1. Every clipboard write is hashed (SHA-256) 2. When content arrives from a peer, its hash is recorded 3. When the local clipboard watcher detects a change, it checks if the hash matches a recently received peer hash — if so, it skips broadcasting +### Reliability + +- Small clipboard content (≤1300 bytes) is sent 3 times for UDP reliability +- Large content is automatically chunked and reassembled on the receiver +- Incomplete chunk transfers are cleaned up after 30 seconds +- Persistent UDP connections to peers with automatic reconnection + ### Limits -- Max clipboard: **10MB** (content >16KB is automatically chunked) +- Max clipboard: **10 MB** (content >1300 bytes is automatically chunked) - Text only (uses `pbcopy`/`pbpaste`) -- macOS only (for now) +- macOS only - Peers must be on the same LAN ## Troubleshooting @@ -188,6 +215,10 @@ Clipboard payload: `[64B SHA-256 hex hash] [clipboard data]` - LaunchAgent logs: `~/Library/Logs/clipx.log` - Live tail: `tail -f ~/Library/Logs/clipx.log` +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + ## License MIT — see [LICENSE](LICENSE). diff --git a/clipx/clipboard.go b/clipx/clipboard.go index 039579a..a34082c 100644 --- a/clipx/clipboard.go +++ b/clipx/clipboard.go @@ -1,3 +1,8 @@ +// Package clipx implements LAN clipboard sync for macOS. +// +// It uses UDP unicast to send clipboard content between explicitly +// paired peers on the same local network. Content is deduplicated +// via SHA-256 hashing to prevent infinite ping-pong loops. package clipx import ( @@ -7,13 +12,17 @@ import ( "strings" ) -// Clipboard abstracts clipboard read/write for testability. +// Clipboard abstracts clipboard read/write operations. +// Implementations must be safe for concurrent use. type Clipboard interface { + // Read returns the current clipboard content. Read() ([]byte, error) + // Write sets the clipboard content. Write(data []byte) error } -// MacClipboard uses pbcopy/pbpaste. +// MacClipboard reads and writes the macOS system clipboard +// using pbcopy and pbpaste. type MacClipboard struct{} // utf8Env returns the current environment with LANG forced to UTF-8. diff --git a/clipx/config.go b/clipx/config.go index 35b613d..3cef588 100644 --- a/clipx/config.go +++ b/clipx/config.go @@ -7,8 +7,9 @@ import ( ) // Config holds persistent clipx configuration. +// It is stored as JSON at ~/.config/clipx/config.json. type Config struct { - Peers []string `json:"peers"` // list of peer IPs/hostnames + Peers []string `json:"peers"` // IP addresses of paired peers } // ConfigPath returns the path to the config file. diff --git a/clipx/net.go b/clipx/net.go index 196665b..1a4b2cc 100644 --- a/clipx/net.go +++ b/clipx/net.go @@ -6,9 +6,12 @@ import ( "time" ) +// DefaultPort is the UDP port used for all clipx communication +// (clipboard sync, ping/pong health checks). const DefaultPort = 9877 -// ResolveAddr resolves a hostname or IP to an IP string. +// ResolveAddr resolves a hostname or IP string to an IPv4 address. +// If addr is already a valid IP, it is returned as-is. func ResolveAddr(addr string) (string, error) { // if it's already an IP, return as-is if ip := net.ParseIP(addr); ip != nil { @@ -27,7 +30,8 @@ func ResolveAddr(addr string) (string, error) { return "", fmt.Errorf("no IPv4 address found for %s", addr) } -// PingPeer sends a ping and waits for a pong to check if a peer is reachable. +// PingPeer sends a UDP ping to the given address and waits up to 1 second +// for a pong response. Returns "● online" or "○ offline". func PingPeer(addr string) string { target := net.JoinHostPort(addr, fmt.Sprintf("%d", DefaultPort)) conn, err := net.DialTimeout("udp4", target, 1*time.Second) diff --git a/clipx/node.go b/clipx/node.go index 237432c..4087b54 100644 --- a/clipx/node.go +++ b/clipx/node.go @@ -15,17 +15,18 @@ const ( pollInterval = 500 * time.Millisecond ) -// Node is a clipx daemon instance. +// Node is a clipx daemon instance that watches the local clipboard, +// broadcasts changes to peers, and applies incoming clipboard content. type Node struct { id string peers []string // peer IPs clipboard Clipboard logger *log.Logger - conn net.PacketConn // listener + conn net.PacketConn // listener peerConns map[string]net.Conn // persistent send connections per peer - mu sync.Mutex - lastHash string // last clipboard hash we've seen (sent or received) + mu sync.Mutex + lastHash string // last clipboard hash we've seen (sent or received) // hashes received from peers — prevents re-broadcasting peerHashes map[string]time.Time @@ -47,7 +48,7 @@ type chunkBuffer struct { createdAt time.Time } -// NewNode creates a new clipx node. +// NewNode creates a new clipx node using the macOS system clipboard. func NewNode(cfg *Config, logger *log.Logger) (*Node, error) { return NewNodeWithClipboard(cfg, logger, &MacClipboard{}) } @@ -83,7 +84,9 @@ func NewNodeWithClipboard(cfg *Config, logger *log.Logger, cb Clipboard) (*Node, return n, nil } -// Start begins the listener, clipboard watcher, and maintenance. +// Start launches three background goroutines: the UDP listener, +// the clipboard poller, and the maintenance loop. Call [Node.Stop] +// to shut down gracefully. func (n *Node) Start() { n.wg.Add(3) go n.listen() @@ -91,7 +94,8 @@ func (n *Node) Start() { go n.maintenance() } -// Stop shuts down the node. +// Stop gracefully shuts down the node, closing all connections +// and waiting for goroutines to exit. func (n *Node) Stop() { close(n.stopCh) n.conn.Close() diff --git a/clipx/protocol.go b/clipx/protocol.go index 86031da..c12b6fb 100644 --- a/clipx/protocol.go +++ b/clipx/protocol.go @@ -33,14 +33,14 @@ const hashLen = 64 const chunkHeaderLen = hashLen + 2 + 2 // hash + index + total const ( - // MaxChunkPayload is the max clipboard data per UDP packet. - // Must stay under WiFi MTU (~1500) minus headers to avoid - // "message too long" on macOS which sets DF (Don't Fragment). - // 1500 MTU - 20 IP - 8 UDP - 15 clipx header - 68 chunk header = 1389 - MaxChunkPayload = 1300 // safe margin under any MTU + // MaxChunkPayload is the maximum clipboard data per UDP packet. + // Sized to stay under WiFi MTU (~1500 bytes) minus IP/UDP/clipx headers + // to avoid "message too long" errors on macOS (which sets DF bit). + // 1500 MTU - 20 IP - 8 UDP - 15 clipx header - 68 chunk header = 1389 + MaxChunkPayload = 1300 // conservative margin for any network - // MaxClipSize is the absolute max clipboard size we'll sync. - MaxClipSize = 10 * 1024 * 1024 // 10MB + // MaxClipSize is the maximum clipboard content size that will be synced. + MaxClipSize = 10 * 1024 * 1024 // 10 MB ) // encodeMessage builds a wire message.