A shared clipboard for all your devices.
Copy on one machine, paste on another. Encrypted, on your own network, no account, no cloud.
henri is a small daemon. It watches your clipboard, and the moment you copy
something it announces the change to your other devices and hands them the new
contents over an encrypted connection. They put it straight on their own
clipboard.
So: ⌘C on the laptop, Ctrl+V on the desktop. That's the whole idea — and it works for text, for images, and for files and folders.
- Fifteen words to set up.
henri initprints a recovery phrase; type it on your other devices. Those words are the group key — there is no server and no account. - Encrypted end to end. Every byte on the wire is sealed with AES-256-GCM under a key only your devices hold.
- Finds your devices by itself. Machines on the same network discover each other. Anything else you list by address.
- Files and images travel too. Copy a file, a folder or an image the way
you always do; paste it on the other machine. Files land in
~/Downloads/henriand on the clipboard as references, so Ctrl+V in the file manager drops real files. Capped at 32 MiB (max_file_bytes) — photos and documents, not films. - Mixed groups work. macOS, Linux and Windows devices all sit in the same
group — the protocol is identical everywhere and text is normalised to UTF-8
with
\nline endings, so a copy on a Mac pastes unchanged on Linux, and gets its\r\nback when it lands on Windows. - Starts at login.
henri service installsets it up as a launchd agent or a systemd user unit and gets out of the way. - Shows its face. On macOS, henri's icon sits in the menu bar for exactly
as long as the daemon runs — glance up to know sync is on. Its menu can push
the clipboard to the group;
"hide_menu_bar_icon": trueturns it off. - One static binary, no dependencies. Nothing outside the Go standard library, no cgo.
The simplest way, no root needed — it lands in $(go env GOPATH)/bin:
go install github.com/justin06lee/henri/cmd/henri@latestOr build it from a clone. Build as yourself, install as root:
git clone https://github.com/justin06lee/henri
cd henri
make build # ./henri
sudo make install # /usr/local/bin/henrimake install deliberately refuses to compile anything — it only copies the
binary you already built. Running the compiler under sudo would scatter
root-owned files through your working tree and break the next ordinary make,
and on a repo git considers foreign (an external drive mounted noowners, a
Docker bind mount owned by another uid) it fails outright.
To skip root altogether, install somewhere you own:
make install PREFIX=$HOME/.local # ~/.local/bin/henriAlready running henri and pulled a newer version? One command does the whole upgrade:
make updateIt stops the background service, sweeps stale copies out of the usual install
locations (~/.local/bin, $(go env GOPATH)/bin), installs the fresh build
where make install would, and brings the service back — but only if one was
installed to begin with. Run it as yourself, never under sudo: it asks for
root by itself, for the one step that needs it.
Linux additionally needs a clipboard helper — wl-clipboard on Wayland, or
xclip/xsel on X11:
sudo pacman -S wl-clipboard xclip # arch
sudo apt install wl-clipboard xclip # debian, ubuntu
sudo dnf install wl-clipboard xclip # fedoraInstall both and henri picks the right one for your session: it prefers
wl-clipboard when WAYLAND_DISPLAY is set, because on Wayland xclip often
talks to an XWayland clipboard your compositor does not share.
macOS and Windows work out of the box (pbcopy/pbpaste, PowerShell).
On your first device:
$ henri init
Started a new clipboard group.
config /home/you/.config/henri/config.json
device laptop
group JS8lEL6zZyc
Your recovery phrase — these 15 words are the whole secret:
1. glue 2. report 3. social 4. awake
5. strike 6. piano 7. arm 8. awesome
9. wage 10. eternal 11. bean 12. smile
13. real 14. release 15. couple
On every other device, run `henri join` and type those words in when
it asks for them.Type those words on every other device:
$ henri join
Recovery phrase (the words `henri init` printed): glue report social awake …
Joined group JS8lEL6zZyc as "desktop".henri join <words> still works and is still in a lot of muscle memory, but it
is the worse way to move a group key: a command line is readable by every other
user on the machine through ps auxww, and your shell writes it into its
history file. With nothing after join, henri asks — and if stdin is not a
terminal it reads the phrase from there, so henri join < phrase.txt works too.
You do not have to be precise about it. Case, punctuation and stray whitespace are all ignored, and because no two words in the list share their first four letters you can abbreviate every one of them:
$ henri join
Recovery phrase (the words `henri init` printed): glue repo soci awak stri pian arm awes wage eter bean smil real rele coupGet a word wrong and henri says so, instead of quietly building the wrong key:
$ henri join
Recovery phrase (the words `henri init` printed): glue report socail awake strike …
henri: mnemonic: word 3: "socail" is not one of the words — did you mean "social"?Get the order wrong and the checksum catches that too:
$ henri join
Recovery phrase (the words `henri init` printed): report glue social awake strike …
henri: mnemonic: that phrase does not check out — every word is real, so one of
them is probably in the wrong place or slightly wrongThen start the daemon on each device:
henri daemonThat's it. Copy something on one device and it is on the others' clipboards before you can switch windows.
To stop babysitting a terminal, install it as a background service that starts at login:
henri service installLost the phrase? henri code prints it again on any device still in the group.
| Command | What it does |
|---|---|
henri init |
Start a new clipboard group on this device |
henri join |
Join an existing group; asks for the recovery phrase |
henri join <words> |
The same, with the phrase on the command line — see the warning above |
henri code |
Print this group's recovery phrase again |
henri daemon |
Run the sync daemon in the foreground |
henri service install |
Run henri in the background, starting at login |
henri service status |
Is it installed, enabled and running? |
henri service logs |
Follow the background daemon's output |
henri service restart |
Restart it after a config change |
henri service uninstall |
Stop it and remove the unit |
henri status |
Show what the local daemon is doing |
henri doctor |
Check everything sync needs and say what is wrong |
henri doctor -fix |
The same, and offer to open the firewall ports |
henri peers |
List known devices |
henri peers add <host:port> |
Add a device that discovery can't reach |
henri peers rm <host:port> |
Remove one |
henri send |
Send the current clipboard to the group |
henri send -highlighted |
Copy the highlighted text here and send it |
henri hotkey install |
Bind a key to henri send -highlighted, for desktops henri cannot watch |
henri hotkey status |
Show the current binding |
henri hotkey uninstall |
Remove it |
henri leave |
Remove this device's config and leave the group |
henri version |
Print the version |
henri status is the one to reach for when something looks wrong:
$ henri status
henri ● running
device laptop (dQw4w9WgXcQ)
group BY_l6z_OVEA
clipboard pbpaste
listening :47600
discovery on · 412 beacons · last 4s ago
uptime 4h12m pid 5512
traffic 38 sent · 51 received
last 1.2 KiB from desktop, 6s ago
peers
● desktop 192.168.1.42:47600 discovered 3s ago
● phone 192.168.1.77:47600 discovered 9s ago
○ — 10.8.0.4:47600 config neverThe discovery line is the one to read when the peer list is emptier than it
should be. "on" on its own used to be all it said, which a daemon whose
multicast membership had been dropped went on reporting perfectly cheerfully
while hearing nothing at all. Now it counts, and complains:
$ henri status
discovery on · 12 beacons · last 17m4s ago
…
⚠ Discovery is on, but nothing has been heard for 17m4s.
Devices announce themselves every 10 seconds, so either nothing else in
this group is running, or this device has stopped hearing them: a Wi-Fi
roam, a suspend or a VPN coming up drops the multicast membership without
telling the socket. henri takes it out again about once a minute.
If another device is definitely running, check `henri status` there, and
that UDP 47601 is allowed on both. To stop depending on multicast:
henri peers add <host:port>henri status exits non-zero when the daemon is not running, so
until henri status; do sleep 1; done is a wait loop that terminates. It still
prints its friendly stopped page on stdout; only the exit status differs.
you press ⌘C
│
▼
┌───────────────┐ polls every 400ms
│ watcher │ has the content changed?
└───────┬───────┘
│ yes
▼
┌──────────────────────────┐
│ seal AES-256-GCM │
│ key HKDF(group key) │
└────────────┬─────────────┘
│ TCP :47600
┌────────┼────────┐
▼ ▼ ▼
┌───────┐┌───────┐┌───────┐
│desktop││ phone ││ vps │
└───────┘└───────┘└───────┘
open · verify · write to the local clipboard
discovery: every 10s each device multicasts an encrypted
"hello" to 239.42.47.60:47601 so the others learn its address,
and re-takes its multicast membership about once a minute so a
network that moves underneath it does not leave it deaf
A few details that matter:
Changes are noticed, not hunted for. Where the system will say when the
clipboard changed, henri listens instead of asking: wl-paste --watch on
Wayland, clipnotify on X11 if it is installed. Both mean henri touches the
clipboard only when something actually happened. henri status shows which one
is in use.
Where neither is available it falls back to checking every 400ms, which works everywhere but has a cost: each check spawns a helper process, and on Wayland reading a selection can require briefly taking keyboard focus. Two and a half times a second, that is a visible flicker.
Discovery re-joins the network it is on. A multicast membership belongs to
an interface, and the kernel drops it when that interface goes — a Wi-Fi roam, a
suspend, a DHCP renew, a VPN coming up. Nothing tells the socket, so a daemon
that was listening happily an hour ago sits there stone deaf while it carries on
announcing itself. henri throws the membership away and takes it out again about
once a minute, and sooner when it has heard nothing for 40 seconds. henri status shows how many beacons it has heard and how long ago, and says so
plainly when the answer is "not for a while".
No echo storms. Each device remembers the SHA-256 of the clipboard content it currently considers in sync. A device claims that fingerprint before it writes an incoming payload, so its own watcher sees the new content as already-known and never bounces it back.
Files are content, not names. A file copy travels as one encrypted gzip'd tar, sized before archiving so a folder of video is refused in milliseconds. The receiver unpacks it warily — no overwrites, no path escapes, no symlinks, decompression capped — and never trusts it: the archive arrived off the network, however well it authenticated. Because the receiver's paths can never match the sender's, files also carry a name-independent content fingerprint, which is what stops a copy echoing between devices the way byte-identity already stops text. A file copy is fingerprinted by path, size and modify time; edit deep inside a copied folder and henri may not notice — copy it again and it certainly will.
Images travel as PNG. Whatever an app put on the clipboard — PNG, or the TIFF many macOS apps still use — it arrives as PNG pixels that paste straight into an editor on the other side.
The phrase is the secret. henri init draws 160 bits from the system CSPRNG
and renders them as fifteen words using BIP-39,
the same scheme cryptocurrency wallets use for seed phrases. The group's master
key and its ID are both derived from that entropy with HKDF-SHA256, so the words
are the only thing worth writing down. Five of the 165 bits are a checksum,
which is what lets henri tell you that word 3 is wrong instead of building a key
that silently never matches.
Two keys, one secret. The 32-byte group key in your config is never used directly. HKDF-SHA256 derives one key for clipboard payloads and another for discovery beacons, so a beacon can't be replayed as a payload. The group ID is mixed in as additional authenticated data.
Membership is the key. There is no login. Sealing a message correctly is what proves a device is in the group; anything that fails to authenticate is dropped without a reply.
Every frame is delivered once. Each message carries a timestamp and is refused if it is more than two minutes away from this device's clock. On its own that left a two-minute window in which a frame captured off the wire was as good as the original — long enough to put an old clipboard back under you at the moment you paste, or to re-home a peer with a replayed beacon. So henri also remembers the random nonce of every frame it has acted on for the length of that window, and refuses the same frame a second time.
Payloads carry a hash of their contents too, but that is a consistency check and not a defence: it travels inside the sealed envelope, so anyone able to change it could change the payload it describes. It catches a bug, not an attacker.
~/.config/henri/config.json (override with $HENRI_CONFIG or
$XDG_CONFIG_HOME; %APPDATA%\henri\ on Windows). It is written 0600 and
henri refuses to start if anyone else can read it — it holds your group key.
That permission check is a Unix one, and henri skips it on Windows, where the
mode bits Go reports say nothing about who can open the file. On Windows keep
%APPDATA%\henri\ somewhere only your account can read.
group_id and key are both derived from phrase, and henri checks that they
still are every time it reads the file. Editing one of them without the other —
or swapping in somebody else's — is refused rather than quietly obeyed, because
a config that syncs to a group other than the one its words name is exactly what
that would look like from the outside.
If ~/.config is a symlink into a dotfiles directory, saving follows it: henri
writes the file the link points at and leaves the link alone.
{
"group_id": "JS8lEL6zZyc",
"key": "ZR+ykCc7xXwnDQkC55jkGw/n4gy66Bd1WPGcavgBvb8=",
"phrase": "glue report social awake strike piano arm awesome wage eternal bean smile real release couple",
"device_id": "-wYRDywIF9A",
"device_name": "laptop",
"listen_port": 47600,
"discovery_port": 47601,
"discovery": true,
"peers": ["10.8.0.4:47600"],
"poll_interval_ms": 400,
"max_payload_bytes": 4194304,
"max_file_bytes": 33554432
}| Key | Default | Notes |
|---|---|---|
phrase |
generated | The recovery phrase. group_id and key are derived from it. This is the credential. |
group_id |
derived | Identifies the group; shared by every device in it |
key |
derived | The 32-byte group secret, base64 |
device_id |
generated | Unique per device; not shared |
device_name |
hostname | What shows up in henri peers |
listen_port |
47600 |
TCP port that receives clipboard updates |
discovery_port |
47601 |
UDP port for multicast beacons |
discovery |
true |
Set false to rely only on peers |
peers |
[] |
Devices to always push to — for anything off the LAN |
poll_interval_ms |
400 |
How often the clipboard is checked, when no event source is available |
clipboard_poll_only |
false |
Ignore event sources and always poll |
max_payload_bytes |
4194304 |
Text larger than this is skipped |
max_file_bytes |
33554432 |
Files, folders and images larger than this are skipped; capped by what one frame can carry (~45 MiB) |
receive_dir |
~/Downloads/henri |
Where files copied on other devices land |
hide_menu_bar_icon |
false |
Turn off the macOS menu bar icon |
Devices that aren't on the same network — a VPS, or a laptop behind a different router — won't be found by multicast. Add them by address:
henri peers add 10.8.0.4:47600
henri peers add 10.8.0.4 # :47600 is assumed
henri peers add [fd00::4]:47600 # IPv6 goes in brackets, as everAn address that is not one — a port that is not a number, or not a port — is refused when you add it rather than accepted and then silently never dialled. Discovery itself is IPv4 multicast; peers listed by address can be either.
Over the open internet, put henri inside a WireGuard or Tailscale network rather than forwarding port 47600. The payloads are encrypted either way, but there's no reason to expose the listener.
Nothing extra to configure — install henri on both, join them to the same group, and they sync. Two things are worth knowing:
- Linux needs a clipboard helper (
wl-clipboard,xcliporxsel), and it has to run inside your graphical session. A daemon started from a bare SSH shell has no clipboard to watch;henri statuswill showclipboard none. - Open the ports. Most Linux distributions ship a firewall that refuses
inbound connections. Run
henri doctor -fixand it will do this for you.
If discovery does not find the other machine, henri peers add <ip>:47600 on
both sides skips it entirely and tells you quickly whether the problem is
multicast or the firewall.
This is worth its own heading because of how badly it disguises itself.
A firewall filters inbound traffic only. A device with a closed port still
announces itself perfectly well and still pushes its own clipboard out — so
both machines list each other in henri peers, everything looks connected, and
copies travel in exactly one direction. It reads like a bug in henri. It is a
closed port on whichever machine is not receiving.
henri doctortells you which machine that is, because it opens a real connection to every peer and says what came back:
$ henri doctor
✓ clipboard pbpaste, readable
✓ daemon running, pid 6613, up 11m1s
✓ listening :47600
✓ discovery on · 66 beacons · last 7s ago
✓ firewall macOS application firewall is active and henri's ports are open
✗ tenet 192.168.1.253:47600
refused — the host answered but turned the connection away, which is a
firewall rejecting it or henri not running there
what to do
1. henri on tenet cannot be reached. That is a firewall on THAT machine,
not this one — open tcp/47600 and udp/47601 there. `henri doctor -fix` on
that device will do it.The distinction it draws is the one that matters. Refused means the packet arrived and was turned away — the host is up, and either a firewall is rejecting or henri is not running there. No answer means the packet vanished, which is a firewall dropping it or a network that will not carry traffic between the two devices at all.
Then, on the machine that cannot be reached:
henri doctor -fixhenri finds the firewall — firewalld, ufw, nftables, iptables — and offers the exact commands to open TCP 47600 and UDP 47601, scoped to your local network rather than to everything. It asks before running anything, and it never edits a hand-written nftables or iptables ruleset: putting a rule in the wrong chain, or after a catch-all drop, is worse than printing it and letting you place it.
By hand, if you would rather:
# firewalld — Fedora, and common on Arch
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port port="47600" protocol="tcp" accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port port="47601" protocol="udp" accept'
sudo firewall-cmd --reload
# ufw — Debian, Ubuntu
sudo ufw allow from 192.168.1.0/24 to any port 47600 proto tcp
sudo ufw allow from 192.168.1.0/24 to any port 47601 proto udpOpen both. Opening only TCP is a trap: the clipboard would transfer if discovery ever worked, and discovery is the UDP port, so it never will.
On macOS there is nothing to open — the firewall there filters by application
and prompts the first time henri listens. If you turned on "Block all incoming
connections", henri cannot receive anything; henri doctor says so.
henri daemon runs in the foreground, which is useful for watching what it
does but not for actually living with. To have it run in the background and
come back at login:
henri service installThat is all of it. On macOS it writes a launchd agent, on Linux a systemd user unit, and starts it straight away:
$ henri service install
Installing henri as a launchd service.
binary /usr/local/bin/henri
unit /Users/you/Library/LaunchAgents/com.justin06lee.henri.plist
Waiting for it to start
henri is running in the background and will start again at login.The rest:
henri service status # installed? running? will it come back at login?
henri service logs # follow its output
henri service restart # after changing the config
henri service uninstall # stop it and remove the unitBoth run as your user inside your graphical session, on purpose: the clipboard belongs to that session, so a system-wide daemon could not reach it.
make update does this for you. By hand, the point is: henri service install is idempotent, and nothing else rewrites the unit file — so an
existing install keeps whatever was written the first time, bugs included.
- macOS. launchd hands a job an environment with no locale at all, and
pbcopy/pbpastetake their text encoding fromLANG, falling back to plain C when it is unset. Every accented character, curly quote, em dash, CJK character and emoji was mangled in both directions — but only under launchd, so it looked fine everywhere it is easy to test. The plist now setsLANG, and brings henri back when it crashes rather than on any exit at all, which used to respawn a daemon that could never start, every few seconds, forever. - Linux. The unit is now wanted by
graphical-session.targetrather thandefault.target, so it starts once there is a display to read rather than before.PrivateTmpis gone: X11's local transport is a socket in/tmp/.X11-unix, so a private/tmpmeantxclipandxselcould never open the display, and where unprivileged user namespaces are off the unit failed outright at226/NAMESPACE.
Until you re-run it, an existing install goes on quietly doing the old thing.
A service records one exact binary path and one config location and runs them forever after, so henri refuses to install if either is somewhere it might not find later:
- A binary on removable media. Point a login service at
/Volumes/SomeDrive/henriand it fails to start every time the drive is out. Install to/usr/local/binfirst. - A config on removable media. This one is easy to miss, because
~/.configis so often a symlink into a dotfiles directory that lives somewhere else. If it resolves onto an external volume, the daemon cannot read it at login: the volume may not be mounted yet, and macOS gates removable volumes behind a permission prompt that a background service has no way to answer — the read simply blocks forever.
Move the config to the internal disk and tell henri where it went:
mkdir -p ~/.henri && mv ~/.config/henri/config.json ~/.henri/config.json
export HENRI_CONFIG=$HOME/.henri/config.json # add this to your shell rc
henri service installhenri service install records HENRI_CONFIG and XDG_CONFIG_HOME in the unit
it writes, so the background daemon reads the same file your shell does. Service
managers start with a bare environment and would otherwise quietly read a
different config.
-force overrides either check if you know what you are doing.
Some compositors will not let any background program read the clipboard. Wayland hands the selection only to the client holding keyboard focus, and where the compositor implements no data-control protocol there is no way around it: reading takes focus, and reading on a timer makes the screen flicker.
henri detects that and stops watching rather than flickering at you. It says so plainly:
$ henri status
clipboard wl-clipboard
watching press-to-send (this compositor cannot be watched in the background)
This compositor only gives the clipboard to the focused window, so henri
cannot notice copies on its own. Bind a key to push them:
henri hotkey installThat binds Super+Shift+C to copy and send in one press:
$ henri hotkey install
Bound Super+Shift+C.
command /usr/local/bin/henri send -highlightedHighlight some text, press the key. It lands on this device's clipboard and on every other device's, with no Ctrl+C in between.
That works because highlighted text is already published: X11 and Wayland both track a PRIMARY selection alongside the clipboard, updated by every app as you drag over text — it is what middle-click pastes. So henri can read what you have highlighted without synthesising a keystroke or asking the compositor for anything it does not want to give. With nothing highlighted the key falls back to sending the clipboard.
Receiving stays automatic — this only affects sending. Pick a different key with
-accel, using GNOME's syntax:
henri hotkey install -accel '<Super><Alt>c'henri can set the binding itself on GNOME. Everywhere else it prints the line to add to your compositor's config:
sway / i3 bindsym $mod+Shift+c exec /usr/local/bin/henri send -highlighted
hyprland bind = SUPER SHIFT, C, exec, /usr/local/bin/henri send -highlighted
If you would rather have automatic syncing and can live with the flicker, poll anyway:
"clipboard_poll_only": trueSymptom: windows flicker or focus jumps every half second while henri runs.
That is the polling fallback. Wayland ties clipboard access to keyboard focus, so unless the compositor implements a data-control protocol, every read briefly takes focus — and polling does that 2.5 times a second. Check what henri is doing:
$ henri status
clipboard wl-clipboard
watching wl-paste --watch ← event-driven, no pollingIf that second line reads polling every 400ms, henri could not find an event
source:
-
X11. Install
clipnotify— it blocks until the selection changes, so henri stops asking entirely.yay -S clipnotify # arch (AUR) -
Wayland.
wl-paste --watchneedswlr-data-control(wlroots: Sway, Hyprland, river) orext-data-control(KDE). Where the compositor has neither, nothing can read the clipboard in the background at all, and the only lever is to poll less often. -
GNOME Wayland specifically. Mutter implements neither, deliberately — a protocol that lets any background process read the clipboard is the hole Wayland set out to close. henri does not poll there at all; see Desktops henri cannot watch for the hotkey, or run henri on X11 where the clipboard is readable without taking focus.
XWayland is not a way around it. GNOME bridges the two clipboards, but only in one direction: an X11 client cannot see what a Wayland client copied (
xclip -oreportstarget STRING not available, andclipnotifynever fires). -
Either. Slow the fallback down, in the config:
"poll_interval_ms": 2000
Or turn event watching off entirely if it misbehaves:
"clipboard_poll_only": true
Restart the daemon after editing (henri service restart).
On Linux a service can start before the graphical session has published
DISPLAY or WAYLAND_DISPLAY, leaving the daemon running but unable to read
anything. henri service install captures those variables from the session you
run it in and writes them into the unit, which avoids the problem in most
setups. If it still happens, henri status says so rather than leaving you to
guess:
clipboard xclip ⚠ not readable: Can't open display
To take one device out:
henri leaveThat removes the local config and nothing else — your other devices carry on without it, and you can rejoin later with the same phrase. henri refuses to do it while the daemon is running, because a running daemon already holds the key in memory and would keep syncing after the file was gone.
It takes the whole key with it: any half-written temporary copy left by an
interrupted save goes too, and where the config is a symlink so does the file it
links to. henri leave names both before it asks.
There is no way to evict a device remotely. If one is lost or you want it out
for good, run henri init somewhere to make a new group and re-join the devices
you still trust; the old phrase then opens nothing that matters.
What henri gives you:
- Clipboard contents are encrypted and authenticated with AES-256-GCM. Nothing on your network can read one, and nothing without the group key can change one in flight: the tag fails and the frame is dropped without a reply.
- A whole frame captured off the wire cannot be delivered a second time. henri remembers the nonce of every frame it has acted on for as long as that frame would still count as fresh.
- Only devices holding the group key can send or receive.
- The config file holding that key is
0600on Unix, and henri refuses to run if it isn't. It also checks that the group in it is still the group its recovery phrase names.
What it does not give you, and you should know:
- The phrase is the key. Anyone who has those fifteen words can read everything you copy, forever. Read them aloud or type them in by hand; don't paste them through a chat app.
- There is no rotation yet. To change the key you re-run
henri initand re-join every device. - Your clipboard has your passwords in it. That's true of any clipboard sync tool. Copying from a password manager will send that password to every device in the group.
- Discovery beacons are encrypted, but their timing and size are visible to anyone on your LAN — they can tell henri is running, not what you copied.
Yes, with room to spare. Fifteen words drawn from a 2048-word list is 2^160 possible phrases. For scale, twelve words — 2^128, which is what secures cryptocurrency wallets holding real money — is already past what anyone can search with any amount of hardware; fifteen is four billion times that again.
The entropy is not where the risk lives. It's in how the words travel: a phrase read aloud in a room stays in that room, and a phrase pasted into a chat app is on someone else's servers forever.
henri init -words 12 is shorter to type and still ample; -words 24 gives you
256 bits if you would rather.
Phrase lengths are always a multiple of three — 12, 15, 18, 21 or 24. Each word
carries 11 bits and the checksum is a 32nd of the entropy, so the word count
works out to 3 x bits / 32 — 128 bits is twelve words — and only those five
come out whole. There is no
such thing as a 16-word BIP-39 phrase.
Found a problem? Open an issue.
- Files are capped, not streamed. A payload is one encrypted frame, so
max_file_bytestops out around 45 MiB. Photos, documents and folders of either travel fine; films and disk images want a syncthing, not a clipboard. - Everyone upgrades together for files and images. They travel as protocol v2, which an old henri refuses whole — deliberately, since the alternative is pasting a tar archive into a document as text. Text keeps syncing with old devices either way.
- On X11, files and images need
xclip.xselcannot name a target and stays text-only; henri prefersxclipwhen both are installed. - Polling is still the fallback. With no event source henri checks every 400ms, which works everywhere but spawns a helper process each time.
- Two daemons on one machine won't discover each other. They share a
clipboard and a multicast port, so only the first to start receives beacons.
Not a problem in the real configuration — one daemon per device — but worth
knowing if you're testing on a single box. Use
peersfor that. - No history. henri syncs the current clipboard; it isn't a clipboard manager.
- Discovery is IPv4 only. Beacons go to an IPv4 multicast group. IPv6
devices sync perfectly well once you list them with
henri peers add, but they will not find each other on their own. - No key rotation. Changing the group key means
henri initsomewhere and re-joining every device. henri serviceis macOS and Linux only. Windows syncs fine, but has no install-at-login integration yet; runhenri daemonfrom a shortcut inshell:startupfor now.- The menu bar icon is macOS only. A Linux tray is a protocol negotiation
with the desktop that no shipped helper performs, and Windows would need a
message pump henri does not have.
henri statusanswers the same question everywhere.
make test # go test ./...
make race # go test -race ./...
make vet
make dist # cross-compile to dist/bin/The tests run whole groups of nodes against a fake clipboard, so they never touch the real one. They cover propagation between peers, the echo guard, foreign-key rejection, replays, oversized payloads, and peer expiry. The mnemonic package is checked against the official BIP-39 test vectors, and the CLI's own tests cover argument handling, address parsing and the formatting helpers — against a config in a temporary directory, never yours.
cmd/henri the CLI
internal/config config file: load, save, validate
internal/mnemonic BIP-39 recovery phrases
internal/secure HKDF key derivation and AES-256-GCM
internal/clipboard per-platform clipboard access
internal/node the daemon: watcher, peers, discovery, protocol
internal/service launchd and systemd integration
internal/hotkey the send-highlighted key binding
internal/firewall firewall detection for `henri doctor`
internal/tray the macOS menu bar icon
internal/pack file and folder payloads: archive, and unpack warily
assets/ the panel image at the top of this README
Named after Henri from Kindergarten WARS — a manga by You Chiba, serialized on Shōnen Jump+ since 2022, about a kindergarten staffed by retired assassins. The image at the top is traced from a panel of him, and a second panel — head bowed, hair over his face — sits in the macOS menu bar while henri runs.
How it gets there is henri all over: a menu bar item needs AppKit, AppKit
needs cgo, and henri does not take cgo — but macOS ships osascript, whose
JavaScript-for-Automation bridge can drive AppKit from a plain process. The
daemon spawns a small supervised script that draws the icon and takes itself
down when the daemon dies, so the icon in the bar and "sync is running" are
the same fact.
No affiliation with the author or Shueisha. The artwork belongs to them — it's here because I like the manga.