Beam a link or photo from your phone (iOS/Android) or any browser and have it land on your PC in real time — over the internet, not just your home Wi-Fi. Pairing is exactly two devices at a time, and a pairing only lasts 2 minutes: once your phone and PC are paired, you get a 2-minute window to send/receive, then the relay forgets everything it was holding (items + uploaded files) and both devices need to re-pair for another go.
- Windows app: Download Beam for Windows — always the latest release. Windows will show a blue "Windows protected your PC" screen the first time you run it, since the installer isn't code-signed — click More info → Run anyway. See "The installer is unsigned" below for why, and why that warning is expected here.
- Chrome extension: not yet published to the Chrome Web Store. Until
then, load it yourself:
chrome://extensions→ enable Developer mode → Load unpacked → select this repo'schrome-extension/folder. - Any other browser/phone: just open beam-wckn2w.fly.dev — it's a full PWA and works with no install at all (Android can also install it and register it as a native Share Sheet target).
These are the only official builds of Beam. Anything claiming to be Beam from another source isn't from this project.
relay-server/— a small Express + SQLite server that accepts links/photos from one paired device and pushes them to the other in real time over Server-Sent Events (SSE). Every pairing ("inbox") is capped at two devices, and once both are present a 2-minute clock starts; when it runs out,lib/sessionCleanup.jsdeletes that pairing's devices, items, and uploaded files.desktop-app/— "Beam", an Electron tray app whose window simply loads the relay's own web page (see below) — it's the exact same UI a phone gets, plus a small native bridge that saves anything received toDocuments/Beam/Pictures/Beamand shows a Windows notification.web-client/— an installable PWA, and the one UI both phone and PC use. It walks through: start a pairing (shows a QR/code) → wait for the other device → once paired, a countdown plus two options, Send and Receive → once the countdown ends, back to the start. On Android it also registers as a native Share Sheet target ("Beam" shows up alongside AirDrop-style options).ios-shortcut/— since iOS doesn't support PWA share targets, one Apple Shortcut ("Beam to PC") fills the same role. Seeios-shortcut/README.md— note the 2-minute session limit affects this flow more than the others, since a shortcut's token stops working the moment its pairing expires.
Every device is authenticated with a long random token scoped to its
pairing. See docs/THREAT_MODEL.md for what this
does and doesn't protect against.
- Node.js 20+ (this machine doesn't have it installed yet — install it before running anything below).
- For the desktop app: Windows (tested target), though Electron itself is cross-platform.
- For local internet-facing testing before real deployment: ngrok or cloudflared.
npm install # installs all three workspaces
cp .env.example relay-server/.env # then edit relay-server/.env if needednpm run dev:relay # starts the relay on http://localhost:3000
npm run test:relay # runs the relay's automated tests
npm run dev:desktop # launches the Electron tray app (talks to localhost:3000 by default)
npm run dev:web # starts the PWA dev server (Vite) for the manual-share pagenpm run dev:relay, confirmcurl http://localhost:3000/healthreturns{"ok":true}.curl -X POST http://localhost:3000/inbox -H 'Content-Type: application/json' -d '{"label":"test"}'→ notetokenandinboxId(this both starts a new pairing and mints its first device token).- Pair a second device:
curl -X POST http://localhost:3000/pair/init -H "Authorization: Bearer <token>"→ notepairingCode, thencurl -X POST http://localhost:3000/pair/claim -H 'Content-Type: application/json' -d '{"pairingCode":"<code>","label":"second device"}'→ note the secondtokenandexpiresAt— this is when the 2-minute session ends. - A third
/pair/initon the same pairing should now fail with 409 — a pairing is exactly two devices. - In one terminal:
curl -N "http://localhost:3000/events?token=<token>"to watch the live stream. - In another terminal:
curl -X POST http://localhost:3000/items/link -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' -d '{"url":"https://example.com"}'— it should appear instantly in the SSE stream. curl -X POST http://localhost:3000/items/photo -H "Authorization: Bearer <token>" -F "file=@some.jpg"thencurl -H "Authorization: Bearer <token>" http://localhost:3000/items/<id>/file -o out.jpgto confirm round-trip.- Wait until
expiresAtpasses, then confirm the same token now 401s and the uploaded file is gone fromrelay-server/data/uploads. npm run dev:desktop— repeat steps 5–7 and confirm the tray app's window shows the item live, shows a Windows notification, and saves it toDocuments\Beam\/Pictures\Beam\.- Run
ngrok http 3000, point a real Android phone's browser at the ngrok HTTPS URL, install the PWA, pair via QR, then share a real link/photo from Chrome/Photos and confirm it reaches the desktop app — including with Wi-Fi off (cellular only), to prove this isn't LAN-limited. - Repeat against the iOS Shortcut (see
ios-shortcut/README.md) using the same ngrok URL. - Only after all of the above pass, deploy to Fly.io (below) and re-pair everything against the production URL.
A real, shareable HTTPS URL with a persistent volume (so pairings survive
restarts/redeploys instead of being wiped), on Fly's free/hobby tier. Full
instructions are in docs/HOSTING.md. Short version:
flyctl launch # first time only — creates the app + fly.toml
flyctl volumes create beam_data --size 1 # persistent volume for the DB + uploads
flyctl deployfly.toml in this repo already has the right env vars, mount, and health
check wired up.
A Raspberry Pi, home server, or a cheap VPS you control, with Caddy in front for automatic HTTPS (required — pairing tokens must never travel over plain HTTP).
- Copy
relay-server/to the box,npm install --omit=dev, set uprelay-server/.env(PORT,DB_PATH,UPLOAD_DIR,CORS_ORIGINset to your web client's real origin). - Run it under
systemdso it survives reboots:Then# /etc/systemd/system/beam.service [Unit] Description=Beam relay After=network.target [Service] WorkingDirectory=/opt/beam/relay-server ExecStart=/usr/bin/node index.js Restart=on-failure EnvironmentFile=/opt/beam/relay-server/.env [Install] WantedBy=multi-user.target
systemctl enable --now beam. - Point Caddy at it for HTTPS termination:
your-domain.example.com { reverse_proxy localhost:3000 } - Build the web client to keep one deployable + one cert:
npm run build:web
relay-server/index.jsalready servesweb-client/distas static files when present, so no further wiring is needed — just make sure the build step above runs beforesystemctl start. - Package the desktop app for distribution with
electron-builder(npm run build -w desktop-app) once you're happy with it; point its defaultRELAY_URLat your real domain.
Getting past Windows Smart App Control / SmartScreen requires a paid
code-signing identity (Azure Trusted Signing or a traditional OV/EV
certificate, both needing identity/business verification) — not something
a personal/hobby project does by default, so this installer isn't signed.
The first time someone runs Beam Setup *.exe, Windows will show a blue
"Windows protected your PC" screen. This is expected, not a sign anything's
wrong: click More info, then Run anyway. Since the source is public
in this repo, anyone concerned can read exactly what the installer does
before running it, rather than trusting a certificate.
- Pairing codes are short-lived (10 min) and single-use; tokens are 32 random bytes, never guessable.
- A pairing is capped at exactly two devices — a third
/pair/initor/pair/claimon the same pairing is rejected. - Once both devices are present, the pairing has a fixed 2-minute lifetime:
lib/sessionCleanup.jssweeps expired pairings every few seconds, deleting their devices, items, and uploaded files, so both tokens 401 on their very next request. - Uploaded files are restricted by MIME type and size, stored under
server-generated filenames (never the client-supplied name) to avoid path
traversal, and the actual bytes are checked against each allowed image
format's real magic number after upload — a spoofed
Content-Typeheader claiming to be an image doesn't get a file accepted on its word alone. POST /inbox(mass inbox creation) and/pair/*(pairing attempts) are both rate-limited per IP, with the read-only status-polling route on its own generous limit separate from the actual pairing mutations so normal use never trips it.- The
/eventsSSE endpoint takes its token as a query parameter (browsers'EventSourcecan't set custom headers) — documented tradeoff, mitigated by requiring HTTPS in deployment, and by the token becoming worthless the moment its pairing expires anyway. - See
docs/THREAT_MODEL.mdfor the full picture.