Send files between your laptop and your iPhone over a custom binary protocol, on your own machines, with nobody else in the path.
One Go binary. Works on the same WiFi, and over Tailscale when the phone is on cellular and the laptop is behind NAT. No third-party service, no port forwarding, no native app — the phone side is a web page the laptop serves, and the relay is a machine you own.
- a Tailscale tailnet — it is the network and the security boundary (see below)
- a Linux laptop to send from and receive on
- any always-on Linux box for the relay — a Raspberry Pi is the norm; x86_64 works identically
- an iPhone (the phone side is a web page, so any modern browser works, but the polish is iOS-shaped)
From the laptop:
git clone https://github.com/saxill/BeamDrop.git
cd BeamDrop && ./packaging/install.shOne command, no sudo, everything user-local: a prebuilt engine binary from this repo's releases (built from source only when no release matches your machine), the desktop app with its own Qt venv, a menu entry, an icon — and then the relay deployed to your always-on machine.
The first run is the only run that asks anything:
- which machine is the relay? — your tailnet's Linux machines are listed and you pick. (Later runs remember the address.)
- the relay's ssh password, once — to install your key with
ssh-copy-id, after which every later run is passwordless.
Two things it will not guess its way past. Ssh must be key-based after that first offer — that is what keeps later runs unattended. And the relay's user account must have lingering enabled: a user systemd service dies with its last login session, so a relay without it would stop the moment the deploy's ssh disconnects. The deploy enables it with passwordless sudo when it can, and otherwise prints the one command to run and stops — rather than deploy a relay that would quietly die.
Pairing is not a step you perform. Your laptop dials the relay and is accepted; your phone opens the relay's page and is accepted; both are then remembered by key and never prompt again. What "accepted" means is the next section.
Your tailnet is the boundary. Pairing's 6-digit code is compared by a
human only when both ends are interactive — beamdrop send between two
machines someone is sitting at. A relay running under systemd has no
terminal and nobody to ask, so it accepts every new peer on its own: the
code is still derived and logged with the peer's name, but nothing
compares it. What carries the weight instead is reachability — the relay
is only reachable from your tailnet, so "who can pair" reduces to "who is
on my tailnet". Treat tailnet membership as full access: a paired device
can upload, and sees the filenames of everything that has passed through.
The weaker door — the Shortcuts upload endpoint — sits behind the same
boundary plus a bearer token. Delete upload.token on the relay and that
door stops being registered at all.
The code is organised by where it runs. The common Go backend lives at the root; each device's specific code lives in its own folder.
. common — the Go backend (cmd/, internal/, packaging/)
├── Phone/ the page the phone opens (copy of the embedded web UI)
├── laptop/ the PySide6 desktop app (beamdrop_ui.py)
└── raspberry-pi/ the always-on relay (systemd unit + deploy script)
The phone-facing web UI is embedded into the Go binary at build time
(internal/webui/static/); Phone/ holds a working copy so the phone-side
source has one obvious home. See Phone/README.md for keeping them in sync.
No installer, no relay — just the binary and a phone on the same network or tailnet:
go build -o beamdrop ./cmd/beamdrop
./beamdrop portalIt prints the URLs to open on the phone, best one first:
beamdrop · inbox: /home/you/Portal/inbox
beamdrop portal — type :send /path/to/file or :q to quit
open one of these on your phone:
https://laptop.tailnet.ts.net:4747 (no warning — use this one)
https://100.64.0.1:4747 (tailnet — works off-WiFi)
https://192.168.1.11:4747 (same WiFi)
Open the first one in Safari. The phone pairs, shows a 6-digit code, and the
laptop asks you to confirm it — check the two match, press y. That is the
only time you will be asked: both sides remember each other afterwards.
That line only appears when Tailscale can issue a real, publicly trusted certificate for this machine's MagicDNS name, and it is worth using because the self-signed alternative costs more than one tap:
- Safari's warning has to be tapped through on every device, and it hard refuses some certificates with no way forward at all;
- a service worker needs a secure context, which a tapped-through certificate does not reliably provide — so the page will not install as an app properly;
- iOS does not extend a manually accepted certificate to WebSockets, so pairing can fail after the page itself has loaded.
The IP addresses below it still work exactly as before, self-signed warning and all. Both certificates are held at once and chosen per connection: the MagicDNS name gets the real one, an IP gets the self-signed one. Nothing here is required — with no Tailscale, or with HTTPS turned off for your tailnet, the line is simply absent.
To enable it: turn on HTTPS Certificates in the Tailscale admin console (DNS page). If beamdrop runs as a non-root user, that user also needs permission to ask tailscaled for a certificate:
sudo tailscale set --operator=$USEROne consequence worth knowing: issuing a certificate publishes that hostname to public Certificate Transparency logs, permanently. The machine stays unreachable from the internet — a tailnet IP is private and Tailscale still gates access — but the name becomes public. Skip this and use the IP if you would rather it did not.
Then drop files either way. :send /path/to/file in the portal pushes to
every connected phone; the drop zone on the page sends the other direction.
Incoming files land in ~/Portal/inbox.
| Command | What it does |
|---|---|
beamdrop portal |
The TUI plus the phone-facing page, sharing one port. This is the thing everything else connects to. |
beamdrop send <file> |
Ship one file to a running portal and exit. |
beamdrop watch <dir> |
Ship every new or changed file in a directory. Pairs once, then streams. |
With no --peer, send and watch work out where to go themselves:
- a peer you have paired with before, at the address it was last reached on — this is what finds a laptop over Tailscale, where broadcast does not reach;
- a broadcast probe on the local network, for a laptop on the same WiFi you have never paired with;
- a portal on this same machine.
So the common case is just:
beamdrop send report.pdfName a host when you want to override that:
beamdrop send report.pdf --peer 100.64.0.1
beamdrop watch ~/Screenshots --peer laptop.tailnet.ts.netFlags: --peer, --port on send/watch; --port, --inbox on portal.
The page is a PWA, so it installs like an app rather than living in a browser tab that iOS suspends and forgets.
In Safari, open the address, then Share → Add to Home Screen. You get the mushroom icon, no browser chrome, and the app shell is cached so it opens instantly instead of showing white while it finds the laptop.
Nothing transferred is cached — files arrive over the WebSocket into memory and are saved by you. Caching them would quietly leave copies on the phone that nothing ever clears.
Opening the app asks the portal what has already passed between the two of you and shows it under an "earlier" divider. Before this the page built its feed purely from live events, so it opened blank every single time and anything sent while it was closed was invisible — which looks identical to the app being connected and doing nothing.
A past file shows an Open button rather than arriving with the history. The page holds no bytes for anything it did not receive this session, so it asks the portal to send that one file — which comes back down the ordinary transfer path and ends up with the same preview and Save link as anything else. Fetching all of them on every reconnect would be worse than the tap, particularly on cellular.
The name a peer asks for is reduced to its base component before it is
joined to anything, and the result is then checked to be a regular file
directly inside the inbox — so neither ../ nor a symlink planted in the
inbox turns this into a way to read the rest of the disk.
A paired device is shown the whole conversation with that machine, not only its own traffic. That is the point — one conversation rather than a separate one per device — but it does mean pairing hands over the inbox's filenames.
Tap the 🔔 in the header and the phone is told when something arrives, even with the app closed. On iOS that is the only way to find out: the system suspends a home-screen app within seconds of backgrounding it, taking the WebSocket with it.
The button only appears when it can actually work, which needs all of:
- a publicly trusted certificate — the Tailscale one above. A tapped-through self-signed certificate does not qualify;
- the app added to the home screen (iOS 16.4+);
- permission, which can only be requested from a tap.
Registration travels over the paired WebSocket rather than an HTTP endpoint, so it inherits the pairing that already happened. An unprotected subscribe endpoint would let any node on your tailnet register to receive your filenames.
This is the one part of beamdrop that talks to the public internet: a push
goes to Apple's push service, which then wakes the phone. Nothing else can —
the phone is asleep and only its vendor can reach it. The payload is
encrypted with keys that service does not have, so it learns that a message
went to a device and roughly how big it was, not what it said. The VAPID
keypair lives in ~/.config/beamdrop/push/ and must not be deleted:
it is baked into every subscription your phone has already made, so
replacing it makes notifications stop silently while the phone still
believes it is subscribed.
The drop zone needs Safari open and a picker tapped. For a share-sheet action instead, the portal accepts an upload:
POST http://<your-laptop>:4747/upload
Authorization: Bearer <token>
X-Beamdrop-Filename: IMG_0001.jpeg
<file bytes>
Get the token (created the first time the portal starts):
cat ~/.config/beamdrop/upload.tokenThen in Shortcuts: Get Contents of URL -> Method POST, Request Body
File, with those two headers. Add it to the share sheet and any photo is
two taps from your laptop.
Two deliberate choices, both worth understanding before you use it:
- Plain HTTP, not HTTPS. Tailscale traffic is already WireGuard-encrypted end to end, so TLS would be double encryption for no gain -- and Shortcuts rejects a self-signed certificate outright, with none of the tap-through Safari offers. HTTPS is precisely what would break it.
- This door is narrower than the front one. The frame protocol
authenticates a peer with X25519 and a code you confirm. This
authenticates a request with a bearer token, which is weaker. So it is
reachable only from the tailnet: a leaked token is not enough on its
own, the caller also has to be a node on your tailnet. If you would rather
not have the door at all, delete
upload.token-- the endpoint stops being registered.
Both ends have to be awake at the same moment, which a laptop is not. Run beamdrop on something that never sleeps and it will hold files for you:
beamdrop portal --relayThe phone always sends to the relay — it is always up, so that send always succeeds. The relay then decides:
phone ──POST──▶ relay (always on)
│
├─ laptop up? ──▶ deliver now, notify on the laptop
└─ laptop down? ──▶ hold on disk
└─ retry ──▶ deliver when it returns
Tell the relay where things go by default:
beamdrop portal --relay --relay-to my-laptopThen a phone just uploads, with no extra headers to configure — every one
of those is a row to type on a phone keyboard. Override per file with
X-Beamdrop-To: some-other-peer, or X-Beamdrop-To: here to keep it on
the relay. A name that is not paired with the
relay is refused at upload time rather than held forever for a machine that
can never be resolved.
--relay-to also governs the relay's page, not just uploads: a file or
message sent from the relay's own web UI is passed on to that same
destination. This is what makes it stop mattering which address you opened.
Install both the relay's page and the laptop's on your phone and either one
reaches the laptop — without it, anything sent to the relay's page landed in
the relay's inbox and simply stayed there, which looks exactly like the app
being connected and doing nothing.
phone ──▶ relay's page ──▶ relay's inbox ──▶ passed on to --relay-to
phone ──▶ laptop's page ──▶ laptop's inbox
Anything arriving from the destination is never passed back to it. On a machine whose whole job is retrying until delivery succeeds, that would not be a glitch that settles down — it would be two machines filling each other's disks.
Note the relay keeps its own copy in its inbox as well as forwarding. On a
Pi that is worth watching: ~/Portal/inbox grows and nothing prunes it.
Spooled files live in ~/.config/beamdrop/spool and survive a reboot of the
relay: the payload is committed before its metadata, so a crash mid-write
can never leave something that looks deliverable. Nothing is deleted until
the destination confirms the file's SHA-256 — while a file is spooled, the
relay holds the only copy.
./raspberry-pi/deploy.sh # from the laptop, in the repoIt resolves the relay in order: the argument (user@host), $PI_HOST, the
host in ~/.config/beamdrop/relay.addr — the same address the laptop app
dials, so the two cannot drift — and, when nothing is known yet, a list of
your tailnet's Linux machines to pick from. Then it: detects the relay's
architecture and builds for it (or fetches the matching release binary when
no Go toolchain is on this machine), copies it across under a temp name so
the running binary is never half-written, renders and installs the systemd
unit, restarts the service, and verifies the relay actually answers on
HTTP. packaging/install.sh runs this at the end of every install.
It handles the two classic traps itself:
- ssh. If the key is not installed yet it offers to run
ssh-copy-idonce (you type the relay's password one time), then proceeds. Every later run is passwordless. - lingering. It checks
loginctl, enables lingering with passwordless sudo when it can, and otherwise prints the one command to run and stops — a user service without linger dies with the ssh session that deployed it, after passing every check.
The only real prerequisites: the box runs Linux, is on your tailnet, and you can sudo on it once.
The unit it ships is rendered per deploy — the template in
raspberry-pi/beamdrop.service has a __RELAY_TO__ placeholder that
becomes the deploying laptop's hostname, because that is the name its
portal presents on the wire:
[Service]
ExecStart=%h/beamdrop portal --relay --relay-to <deploying-laptop>
Restart=alwaysThe upload token the phone's shortcut needs is generated by the portal
itself on first run (~/.config/beamdrop/upload.token on the Pi). With
--relay-to pointing at your laptop, a phone upload with no destination
header lands on the laptop; X-Beamdrop-To overrides per upload.
Tailscale is the network. Beamdrop is everything above it.
One port, two dialects. The portal listens once and routes each
connection by its first two bytes: a TLS ClientHello goes to the HTTPS
server for the phone, anything else is a raw beamdrop peer. One byte would
not be enough — 0x16 is the TLS handshake record type but also a valid
frame length — so the second byte disambiguates.
Frames. [len:u32 LE][type:u8][payload], little-endian throughout, 17
frame types. A transfer is FILE_OFFER → FILE_ACCEPT → CHUNKs (64KB) →
FILE_DONE carrying the receiver's computed SHA-256. The sender does not
consider a file delivered until the receiver confirms that hash.
Pairing. X25519 key exchange, then
BLAKE2b-256(sorted(pubA, pubB)) mod 1e6 gives the 6-digit code both sides
display — sorted, so it does not matter who dialled. The shared key is
BLAKE2b(ECDH ‖ code), and both sides prove they derived the same one with
an HMAC-SHA256 challenge/response before anyone is asked to look at a code.
The browser runs the same ceremony as the Go side, using vendored @noble primitives bundled into one file with no runtime dependencies.
Discovery. The portal answers UDP probes rather than beaconing, so a
one-shot send gets an answer in milliseconds and an idle laptop is not
putting packets on the wire every two seconds forever. The datagram is its
own small format — magic, version, kind, TCP port, public key, name — and
the reply goes back unicast, so probing does not tell the whole subnet who
is here.
Trust on first use. Each machine keeps a long-lived keypair; a paired peer is remembered by public key and never prompts again. A peer whose key has changed is refused rather than silently re-paired.
~/Portal/inbox/ received files
~/.config/beamdrop/identity.key this machine's keypair (0600)
~/.config/beamdrop/known_peers/ remembered peers
~/.config/beamdrop/webui-cert.pem the self-signed cert, for IP addresses
~/.config/beamdrop/tailscale-cert.pem the real cert, for the MagicDNS name
The two certificates are kept apart on purpose: the self-signed one is regenerated whenever this machine's addresses change, which would be exactly the wrong thing to do to the other.
Delete a file under known_peers/ to forget a peer and pair again.
cmd/beamdrop/ subcommand dispatch
internal/frame/ the wire format
internal/pairing/ X25519, the 6-digit code, TOFU, machine identity
internal/transfer/ sender/receiver state machine, SHA-256 verification
internal/engine/ one engine per peer connection; Registry holds N
internal/netmux/ first-byte port sharing between TLS and raw frames
internal/discovery/ UDP probe/answer, so send needs no address
internal/webui/ TLS server, WebSocket→frame shim, embedded page
internal/push/ VAPID identity, subscriptions, Web Push delivery
internal/webui/static/ the page the phone runs
internal/mode/ portal (TUI + server), send, watch
internal/smoke/ the real browser JS against a real engine over TLS
go test ./...internal/smoke is the one that matters most: it runs the actual page
scripts under Node against a live webui.Serve over a real TLS WebSocket,
so the Go↔JS boundary is exercised rather than assumed.
The JS unit suites need Node ≥ 22:
cd internal/webui/static && node --test ./*.test.mjs- Resume.
FILE_ACCEPTcarries aresume_fromfield that is always 0. - Streaming on the phone. The page holds a whole file in memory, so very large videos will struggle from the drop zone. The Shortcuts upload path is unaffected.
- Saving to Photos. A web page cannot; the page offers a download link instead.
- Renewal without privilege. A Tailscale certificate is good for 90 days
and beamdrop re-asks on every start, but if the user it runs as cannot
reach tailscaled (see
--operatorabove) it will keep serving the existing certificate until that one expires, then fall back to self-signed.