For months I shared screenshots from my phone to my Mac by sending
myself a Microsoft Teams DM and accepting
image(15).png filenames as the cost of doing business.
One coffee and one prompt later, I had my own app that did exactly
what I wanted, and nothing I did not.
I move a screenshot, a document, or a link from my Samsung
phone to my Mac several times a day. I have tried more than a
dozen apps to do it. They were either broken, cumbersome,
ad-laden, or wanted a monthly subscription for a feature I
could describe in two sentences. The promising ones quietly
disappeared the next time I needed them. The professional ones
wanted me to sign in, accept a privacy policy, and trust their
cloud with files I was about to immediately delete.
My fallback for months was Microsoft Teams. I would send
myself a personal message from the phone, walk back to the
Mac, find the thread, click the file, and accept that every
screenshot was now named image(15).png. The chat
history kept growing. The filenames kept getting worse. The
app I actually wanted was embarrassingly simple to describe.
A share-sheet entry on the phone. Files land in
~/Downloads. Text and links land in a Teams
channel I can search later. No clutter. No subscription. No
third party in the middle.
One Coffee, One Prompt
One Saturday morning I sat down with coffee, opened a Claude
session, and wrote out what I actually wanted. The prompt did
not arrive on the first try. There were a few rounds of
back-and-forth, the kind of refinement that turns a vague idea
into something a model can build against. After the first pass
and a couple of clarifications, the prompt looked like this.
The Initial Prompt
Build a cross-device sharing system that lets me send files, links, and text from my Android phone to my Mac desktops through a self-hosted relay on my Raspberry Pi. Three native components, no cloud storage dependencies.
Relay server (PHP + Node.js on Pi):
- PHP API endpoints behind Apache with Bearer token auth on every request
- POST /api/share.php — receives multipart uploads from Android, stores files in a pending/ directory with JSON metadata sidecars
- GET /api/download.php — serves files to Mac clients with per-device tracking
- GET /api/status.php — health check and pending file list with lazy TTL cleanup (7-day expiry)
- POST /api/register.php — device registration
- Node.js WebSocket server on a dedicated port (PM2-managed, Apache-proxied) that pushes file_ready notifications to connected Macs when PHP receives a new share
- No database — all state is filesystem (pending files, JSON metadata, devices.json registry)
- Multi-device fan-out: files stay on disk until every registered Mac downloads them, tracked via pending_devices and downloaded_by arrays in metadata with flock for concurrent writes
Mac menubar agent (Swift, SPM executable):
- Runs as accessory app (menubar icon only, no Dock presence)
- Persistent WebSocket connection to relay with auto-reconnect (exponential backoff, capped at 30s)
- On file_ready message: downloads file via HTTP GET with device_id, saves to ~/Downloads, shows macOS notification
- Drains backlog on reconnect (fetches pending files from status endpoint for files missed while offline)
- Preferences window: relay URL, API key, device name, download directory, launch at login
- Auto-update checker against GitHub Releases every 48h
- Zero external dependencies — Foundation, AppKit, SwiftUI only
Android share target (Kotlin):
- Appears in system share sheet for text, links, images, and files
- WorkManager-based upload queue with network constraint and exponential backoff retry (3 attempts)
- OkHttp3 multipart POST to relay
- API key stored in EncryptedSharedPreferences
- Files copied to cache before upload to avoid losing content provider access
Domain served via Cloudflare Tunnel. Single shared API key authenticates all components. Timing-safe token comparison everywhere.
About an hour later I had a working product. Not a prototype.
The Android share sheet was wired in, the Pi was relaying, and
the Mac was getting notifications when a file landed. I was
sharing a screenshot to my desktop while I was still in the
kitchen.
What I Built
Three native components, no cloud, around 1,500 lines of code
total. The architecture is small enough to fit in a single
diagram, and small enough to keep in my head when something
needs fixing.
Figure 1: SyncToDesktop System Architecture
Android share target (Kotlin)
Appears in the system share sheet for text, links, images, and
files. WorkManager queues each upload with retry and a network
constraint, so if I share something on the train it ships when
the signal comes back. The API key lives in
EncryptedSharedPreferences. I sideloaded the APK
onto my phone because Play Store certification was not worth
the overhead for an app that has exactly one user.
Pi relay (PHP + Node.js)
Apache serves four PHP endpoints behind a Bearer token, with
timing-safe comparison on every request. A small Node.js
WebSocket server on a dedicated port (PM2-managed,
Apache-proxied) pushes a
file_ready message to every connected Mac the
second a new file lands. There is no database. State lives on
disk as JSON sidecars next to the files themselves, with file
locks for the only concurrency case that actually exists. The
Pi is exposed to the public internet through a Cloudflare
Tunnel, so the phone reaches it from anywhere with no
port-forwarding and no static IP.
Mac menubar agent (Swift, SPM)
Runs as an accessory app, no Dock presence, just a small icon
in the menubar. It holds a persistent WebSocket connection to
the relay with exponential-backoff reconnect. On a
file_ready message it pulls the file with a
device id, drops it in ~/Downloads with the
original filename preserved, and posts a macOS notification.
The agent checks GitHub Releases every 48 hours and updates
itself, so distribution is a git tag instead of a
build pipeline.
The Second Mac
The first version had no concept of multiple Macs. It worked
perfectly for the one case I tested at the kitchen table,
which was the kitchen table. Two days later I packed for a
trip with my 13" MacBook Air and noticed that whichever
machine grabbed the file first was the only one that got it. I
added a device registry, a per-file
pending_devices array, and a
downloaded_by array that gets appended under a
file lock. Files now stay on the relay until every registered
Mac has pulled a copy. The agent drains a backlog on
reconnect, so anything shared while a laptop was asleep on a
plane lands as soon as it wakes up.
A later pass closed a small download race that could have
leaked a file to an unauthorized device by moving the auth
gate ahead of the bytes-on-the-wire call. Nothing about that
fix felt heavy. I noticed the issue on a Thursday afternoon,
traced it in a Claude session while watching coffee brew, and
shipped the fix to both Macs and the phone before the cup was
empty. There is no support team. There is no on-call rotation.
There is the customer, the architect, the QA, and the model,
all in one window, all on the same side of the problem.
The Plan, in Full
The short prompt above is what I started with. The longer
document below is what would let Claude rebuild the whole
system from scratch on a fresh Pi: directory layout, auth
model, share pipeline, multi-device tracking, the Swift file
map, the Kotlin file map, the setup sequence, and every design
decision worth defending. It is the artifact you would hand to
a model if you wanted your own version of this running by
dinner.
The Bigger Point
This is the part of the post I actually care about. With a
homelab, a Claude subscription, and an afternoon, you are now
your own software shop for the long tail of personal
annoyances no commercial app will ever solve cleanly enough.
You skip the trials, the subscriptions, the ads, the trackers,
and the support tickets that go unanswered for weeks. You
become your own customer support, paired with a model that
reads carefully and writes carefully.
One-user apps with zero distribution overhead
Iteration on the timescale of "I noticed something annoying
yesterday"
Privacy by default, because nothing leaves your hardware
Skills that compound, because every app you build teaches
the next one
The leverage was not in the code. The leverage was in deciding
to write the code at all.
Got a personal annoyance you would build your way out of?
SyncToDesktop is a private project, but the pattern travels.
If you have a homelab and a workflow that no off-the-shelf app
handles cleanly, start a conversation.
# SyncToDesktop: Recreation Guide for Blog Post
## Context
SyncToDesktop is a cross-device sharing system built from scratch in five days (April 20-23, 2026). The problem: getting files, links, and text from an Android phone to one or more Mac desktops with zero cloud storage dependency, no subscription, and instant delivery. The system uses a self-hosted Raspberry Pi relay as the bridge, with real-time WebSocket push so files appear on the Mac within seconds of sharing from Android.
This document walks through recreating the entire system on your own hardware. Three native components, no Electron, no React Native, around 1,200 lines of code total.
## Architecture Overview
```
┌──────────────┐ HTTPS POST ┌──────────────────┐ WebSocket ┌──────────────┐
│ Android App │ ──────────────────→ │ Pi Relay │ ───────────────→ │ Mac Agent │
│ (Kotlin) │ /api/share.php │ (PHP + Node.js) │ file_ready │ (Swift) │
│ Share Sheet │ │ Apache + PM2 │ │ Menubar App │
└──────────────┘ └──────────────────┘ └──────────────┘
│
Cloudflare Tunnel
(public HTTPS + WSS)
```
**Data flow:** Android share intent → HTTP multipart upload → PHP stores file on disk, writes metadata JSON sidecar → PHP POSTs to local Node.js WebSocket server → WS broadcasts `file_ready` to all connected Macs → Mac downloads file via HTTP GET → file lands in ~/Downloads with notification.
**No database.** All state lives in the filesystem: files in `pending/`, metadata in JSON sidecars, device registry in `devices.json`. Cleanup is lazy (TTL-based, triggered on status polls).
## Prerequisites
| Component | Requirement |
|-----------|-------------|
| Relay server | Any Linux box with Apache, PHP 8.2+, Node.js 18+, PM2. Raspberry Pi works well. |
| Public access | Cloudflare Tunnel, ngrok, or any reverse proxy giving you HTTPS + WSS on a domain. |
| Mac agent | macOS 14+ (Sonoma), Xcode CLI tools (for `swift build`). |
| Android app | Android Studio, Kotlin 2.x, target SDK 34+. |
| Shared secret | One API key (random string, 32+ chars) used by all three components. |
## Part 1: The Relay Server
The relay has two processes: PHP endpoints served by Apache, and a Node.js WebSocket server managed by PM2. Apache proxies WebSocket upgrades to Node.
### 1.1 Directory Layout on the Pi
```
/ssd/www/sync.yourdomain.com/
├── .htaccess # Security rules, WS proxy
├── index.php # 403 catch-all
├── api/
│ ├── share.php # Upload endpoint (76 lines)
│ ├── download.php # Download with device tracking (89 lines)
│ ├── register.php # Device registration (30 lines)
│ └── status.php # Health check + file list (67 lines)
├── websocket/
│ ├── server.js # WS server (132 lines)
│ └── package.json # ws dependency
└── pending/ # File storage (created by PHP, www-data writable)
/ssd/config/sync.yourdomain.com.php # Config file (OUTSIDE web root)
```
### 1.2 Config File
Lives outside the web root. PHP loads it via `bootstrap.php`.
```php
<?php
return [
'api_key' => 'YOUR_RANDOM_SECRET_HERE',
'teams_webhook_url'=> '', // optional, for text/link forwarding
'pending_dir' => '/ssd/www/sync.yourdomain.com/pending',
'max_file_size' => 50 * 1024 * 1024, // 50 MB
'ws_notify_port' => <port>,
];
```
### 1.3 Authentication Model
Every endpoint requires `Authorization: Bearer <api_key>`. The `bootstrap.php` file handles this before any endpoint code runs. Token comparison uses `hash_equals()` to prevent timing attacks.
WebSocket clients authenticate by sending `{"type":"auth","key":"...","device_id":"...","device_name":"..."}` within 5 seconds of connecting. The WS server validates the key against the same `SYNC_API_KEY` environment variable.
Internal communication (PHP → Node.js `/notify`) uses an `X-Internal-Token` header with the same API key, validated with `timingSafeEqual()`.
### 1.4 The Share Pipeline
When Android POSTs to `/api/share.php`:
1. Auth check (Bearer token).
2. Validate `type` field: `link`, `text`, or `file`.
3. For files: generate random 32-hex-char ID, sanitize extension (block `.php`, `.sh`, etc.), store in `pending/`.
4. Write metadata JSON sidecar with `pending_devices` (all registered device UUIDs) and empty `downloaded_by` array.
5. POST to `http://localhost:<port>/notify` with file metadata. Node.js broadcasts to all authenticated WS clients.
For text/links: optionally forward to a Teams webhook (Adaptive Card format). No file stored.
### 1.5 Multi-Device Download Tracking
The key design decision: files stay on disk until every registered device downloads them.
- `pending_devices`: populated from `devices.json` at share time.
- `downloaded_by`: appended on each download, protected by `flock(LOCK_EX)` for concurrent access.
- When `downloaded_by` contains all entries from `pending_devices`, file + metadata get deleted.
- Files also expire after 7 days via lazy cleanup in `status.php`.
### 1.6 WebSocket Server (Node.js)
Single dependency: `ws` package. Runs on port <port>, bound to `localhost` only (Apache proxies external connections).
Key behaviors:
- 5-second auth timeout on new connections.
- 30-second ping/pong heartbeat (terminates unresponsive clients).
- `POST /notify` endpoint for PHP to push `file_ready` messages.
- `GET /health` returns client count and uptime.
PM2 manages the process: `pm2 start server.js --name sync-ws --env SYNC_API_KEY=<key> WS_PORT=<port>`.
### 1.7 Apache Configuration (.htaccess)
Critical rules:
- Pass `Authorization` header to PHP-FPM (`SetEnvIf`).
- Block direct access to `/pending/` and `/websocket/` directories.
- Block dotfiles, config files, and anything outside `/api/`.
- Proxy WebSocket upgrades: `RewriteRule ^ws$ ws://localhost:<port>/ [P,L]`.
- Security headers: `nosniff`, `DENY` framing, `no-referrer`.
### 1.8 Permissions
Apache's `www-data` user needs write access to `pending/` and the parent directory (for `devices.json`). The config file needs read access only.
```bash
sudo chown -R www-data:www-data /ssd/www/sync.yourdomain.com/pending
sudo chmod 755 /ssd/www/sync.yourdomain.com/pending
```
## Part 2: The Mac Agent
A Swift Package Manager executable that runs as a menubar-only app (no Dock icon). About 700 lines of Swift across 7 files, zero external dependencies.
### 2.1 Source Files
| File | Purpose | Lines |
|------|---------|-------|
| `SyncToDesktopApp.swift` | Entry point, singleton guard, notification setup | ~60 |
| `StatusBarController.swift` | Menubar icon, menu items, file reception orchestration | ~140 |
| `WebSocketManager.swift` | WS connection, auth, reconnect, backlog drain | ~250 |
| `FileDownloader.swift` | HTTP download, file collision handling, notifications | ~100 |
| `AppSettings.swift` | UserDefaults persistence, device ID generation | ~80 |
| `PreferencesView.swift` | SwiftUI settings form | ~90 |
| `UpdateChecker.swift` | GitHub Releases version check, download + install | ~130 |
### 2.2 App Lifecycle
1. `@main` struct creates `NSApplication` in accessory mode (menubar only).
2. Checks for duplicate instances via bundle ID.
3. Requests notification permissions.
4. `StatusBarController` creates menubar icon and `WebSocketManager`.
5. Connects to relay WebSocket immediately.
### 2.3 WebSocket Connection
- Connects to `wss://yourdomain.com/ws`.
- Sends auth JSON with API key, device UUID, device name.
- On `auth_ok`: registers device via `POST /api/register.php`, then drains backlog via `GET /api/status.php?device_id=<uuid>` to catch files shared while offline.
- On disconnect: exponential backoff reconnect (2^n seconds, capped at 30s).
### 2.4 File Reception
When a `file_ready` message arrives:
1. `FileDownloader` constructs `GET /api/download.php?id=<id>&device_id=<uuid>` with Bearer auth.
2. Downloads to temp directory, validates HTTP status.
3. Moves to `~/Downloads` (or configured directory). Handles filename collisions by appending `-1`, `-2`, etc.
4. Posts macOS notification: "SyncToDesktop: filename saved to Downloads".
5. Sends `download_confirmed` back to WS server.
### 2.5 Device Identity
Device UUID auto-generated on first launch, persisted in `UserDefaults`. Device name defaults to Mac hostname, editable in Preferences. Both sent with every auth message and download request, used by the relay to track which devices have received which files.
### 2.6 Auto-Update
Checks `https://api.github.com/repos/<owner>/<repo>/releases/latest` every 48 hours. Compares semantic version against `CFBundleShortVersionString` from Info.plist. If newer: downloads `.zip`, extracts to Desktop, posts notification with "Install" action. Install action runs a shell script that copies the new `.app` over the current one and terminates.
### 2.7 Building
```bash
cd MacAgent
./build-app.sh
# Output: build/SyncToDesktop.app
open build/SyncToDesktop.app
```
The build script runs `swift build -c release`, then assembles the `.app` bundle structure (MacOS binary + Info.plist). No Xcode project needed.
### 2.8 Launch at Login
Two mechanisms available:
- `SMAppService` (modern macOS API) toggled from Preferences UI.
- Bundled `com.mdpsync.SyncToDesktop.plist` for manual launchd installation.
## Part 3: The Android App
Kotlin share target using WorkManager for reliable queued uploads. About 300 lines of Kotlin across 4 files.
### 3.1 Source Files
| File | Purpose |
|------|---------|
| `ShareReceiverActivity.kt` | Handles `ACTION_SEND` intents, routes text/files |
| `UploadWorker.kt` | WorkManager worker that executes HTTP uploads |
| `SyncApi.kt` | OkHttp3 multipart POST to relay |
| `Prefs.kt` | Encrypted SharedPreferences for API key storage |
| `SettingsActivity.kt` | Configuration UI (relay URL + API key) |
### 3.2 Share Flow
1. User taps "Share" on any content → system shows SyncToDesktop as target.
2. `ShareReceiverActivity` extracts content: text, URL, or file URI.
3. Files copied to app cache directory (avoids losing access to content provider URIs).
4. `UploadWorker` enqueued via WorkManager with network constraint.
5. Worker constructs multipart POST to `/api/share.php` with OkHttp3.
6. On success: shows notification. On failure: retries up to 3 times with exponential backoff.
7. Cache file deleted after upload completes.
### 3.3 Security
API key stored in `EncryptedSharedPreferences` (AES256-GCM). Relay URL in plain SharedPreferences (not secret). All HTTP traffic over HTTPS.
### 3.4 Dependencies
```kotlin
// build.gradle.kts
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
```
## Part 4: Putting It Together
### 4.1 Setup Sequence
1. **Generate API key:** `openssl rand -hex 32`
2. **Deploy relay:** Copy PHP files + Node.js server to Pi. Create config file with API key. Set up Apache vhost, install PM2, start WS server.
3. **Set up tunnel:** Configure Cloudflare Tunnel (or equivalent) pointing to Apache on port 80/443, with WebSocket support enabled.
4. **Build Mac agent:** `swift build -c release`, run `build-app.sh`. Launch app, enter relay URL + API key in Preferences.
5. **Build Android app:** Open in Android Studio, build APK. Install, configure relay URL + API key in Settings.
6. **Test:** Share a file from Android, watch it appear on Mac desktop.
### 4.2 Verification
```bash
# Health check
curl -H "Authorization: Bearer <key>" https://yourdomain.com/api/status.php
# Share a test file
curl -X POST -H "Authorization: Bearer <key>" \
-F "type=file" -F "[email protected]" \
https://yourdomain.com/api/share.php
# Check WebSocket server health
curl http://localhost:<port>/health # from the Pi itself
```
## Design Decisions Worth Noting
**Why no database.** The relay handles maybe 10 files a day. A `pending/` directory with JSON metadata sidecars is simpler to deploy, debug, and back up than any database. File locking via `flock()` handles the only concurrency concern (two Macs downloading simultaneously). Lazy TTL cleanup means no cron job needed.
**Why PHP for the API.** The relay runs on a Pi that already has Apache + PHP-FPM for other sites. Adding four endpoint files is zero-overhead deployment. PHP's file handling (`move_uploaded_file`, `readfile`) maps directly to what the relay does.
**Why a separate Node.js process for WebSocket.** PHP cannot hold long-lived connections. The WS server is 132 lines, managed by PM2, and communicates with PHP via a single internal HTTP POST. Apache proxies external WS connections to it.
**Why native apps instead of a web interface.** The Mac agent needs to run in the background as a menubar icon with system notifications. The Android app needs to appear in the system share sheet. Both are fundamentally OS-level integrations that a web app cannot provide.
**Why bearer token auth instead of OAuth/JWT.** Single user, single API key, no sessions to manage. The key authenticates both the Android uploader and the Mac downloader. `hash_equals()` and `timingSafeEqual()` prevent timing attacks. The threat model is "someone guessing the API key," and a 256-bit random token handles that.
**Why multi-device fan-out.** If you have two Macs, both should get the file. The `pending_devices` / `downloaded_by` arrays in metadata track delivery per device. Files stay on disk until the last registered device downloads or TTL expires.
**Why WebSocket instead of polling.** Instant delivery. The Mac agent maintains a persistent WS connection; when Android shares a file, the Mac notification appears within 1-2 seconds. Polling would add latency and waste resources on idle checks.
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2026-04-20 | Initial release: Android share target, Pi relay, Mac menubar agent |
| 1.0.1 | 2026-04-21 | Offline queue drain on reconnect, auto-update via GitHub Releases, notifications, CI/CD |
| 1.0.2 | 2026-04-21 | Multi-device fan-out, device registry, 7-day TTL, device naming |
| 1.0.3 | 2026-04-21 | WebSocket connection leak fix, download race condition fix |
| 1.0.4 | 2026-04-23 | Auth-before-readfile security fix, flock for concurrent metadata writes, regression tests |
## Line Counts
| Component | Lines of Code |
|-----------|---------------|
| Relay PHP (4 files + bootstrap) | ~405 |
| Relay WebSocket (server.js) | ~132 |
| Mac Agent (7 Swift files) | ~700 |
| Android App (5 Kotlin files) | ~300 |
| **Total** | **~1,537** |