Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
76 changes: 76 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 41 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,25 +24,37 @@ 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 <ip>`, 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
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):

Expand All @@ -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**:

Expand Down Expand Up @@ -144,29 +161,39 @@ 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 |

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
Expand All @@ -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).
13 changes: 11 additions & 2 deletions clipx/clipboard.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion clipx/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions clipx/net.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
18 changes: 11 additions & 7 deletions clipx/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{})
}
Expand Down Expand Up @@ -83,15 +84,18 @@ 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()
go n.watchClipboard()
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()
Expand Down
14 changes: 7 additions & 7 deletions clipx/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading