Back to work

Personal · 2026 · Design and engineering

Raven

Direct, encrypted file transfer between two machines on the same network, built because every other way of reaching my own files was worse.

The shipped app. One window: a drop stage, a rail naming this device and the devices it can see, and a full width history with search and a control that masks file names.

The problem

I bought a Mac with nothing but USB-C ports. The external drive I keep most of my work on is USB-A. So reaching my own files came down to buying an adapter I resented needing, or reformatting a drive that was working perfectly well, and I was not doing either.

The usual answer is Google Drive or Dropbox, or emailing the thing to yourself. Upload it, wait, then download the same file onto a machine sitting a foot away. It is slow once the file is any size, it wants an account, and it leaves a copy of my work on someone else's server for no reason other than that the two machines could not talk to each other directly.

The tools that avoid the detour mostly look like network utilities, which is to say they want you to understand addresses and ports before they will help. So the target was a calm single window anyone can use, sitting on a transfer core that takes security seriously enough to deserve the thing it is moving.

There are almost certainly other apps that solve this, and I did not go looking for them. Building something to fix a problem I actually have is most of why I got into software, and it is still the most reliable way I know to end up with something I use every day.

Design thinking

Before the app existed, the interface was argued out as a set of clickable mockups. Competing layouts are hard to choose between in the abstract and easy to choose between when they are sitting next to each other, and structure is the one decision that gets expensive to revisit once there is code attached to it.

Three layout directions were built against each other rather than described: a centred canvas that gave the animation the whole stage, a stage with a side rail keeping live transfers and known devices permanently visible, and a stacked hero over a list that showed the most at a glance. The rail won, on the argument that a transfer tool should let you see what is happening without going to look, and history was then pulled out of the rail into a full width region of its own.

Some decisions were set as rules rather than drawn as screens. No gradients. No card or box components anywhere, because cards read as un-premium, so structure is carried by hairlines and whitespace. One flourish only, a raven whose horizontal position is bound to real transfer progress so it lands when the file does and never early.

Three layout directions built side by side, each carrying its own argument underneath.

The shipped journey

How a transfer actually runs, from opening the app to watching the file land.

  1. 01

    One window, waiting

    The drop stage, a rail naming this device with its address and listening state alongside the devices it can see, and history underneath. The empty-state copy was tightened after launch, both to name folders as first class and to drop a verification claim that was overstating what happens.

  2. 02

    Six digits that never travel

    Pairing runs once per pair of devices. The same six digits appear on both, derived from both key fingerprints and the TLS session secret, computed on each side and never sent across the wire. The comparison is the entire security boundary, which is why it is a step rather than a formality.

  3. 03

    Authorise the transfer, not just the device

    Pairing authenticates the device. This prompt authorises the individual transfer, and it prompts every time by default. Auto-accept from a paired device exists as a setting rather than a default, because the safe behaviour should be the one you get without reading the preferences.

  4. 04

    Watch it move

    A 4.7 GiB archive at 834 MiB/s. The endpoints are named on their own row, the flyer has its own track below them, and its position tracks the bar exactly. Both sides can cancel, and cancelling deletes the partial file rather than leaving something corrupt on disk.

Improvements after QA

Folders were not supported at all. Dropping one was refused outright, because the original command line tool errored on a directory and told you to zip it first, and the receiver deliberately flattened every path for safety. The easy fix was to zip a folder automatically and send that. I took the harder one, because a folder that arrives as a zip is not the thing you sent. Folders now walk recursively, each file carries a folder-relative path, and the tree is rebuilt on the far side with traversal attacks blocked and a colliding folder name renamed once.

The next thing I hit was dead air. I confirmed a pairing code and the dialog just sat there with both buttons still showing, giving no sign it was waiting on anything. Sending was worse: a blank screen until the other person accepted or declined. Both now name who is being waited on and offer a way out. Getting the name in there meant the Go core had to start carrying the paired device's friendly name into its session event, because the interface previously had only a raw address to show.

Pairing also threw away the send I was making. I picked files, sent to a device I had not paired with, got an error, paired, then had to go back and pick everything again. Once is a shrug. It is worth fixing because it happens every time you meet a new device, and small repeated friction is what makes people quietly stop using something. The pending send now survives the detour and fires itself the moment pairing succeeds.

The progress bar was lying about folders. It filled and reset for every file inside, so the raven flew the full width once per file and a hundred-file folder read as a hundred transfers. It now measures total bytes across the whole session, one continuous bar and one flight, which again meant widening what the core reports at session start.

A folder then arrived as a hundred separate events. Ninety-six files produced ninety-six history rows and ninety-six notifications, which reads as chaos rather than one thing landing. Rows collapse to one per folder with a file count and a total size, and a chevron expands the contents in place rather than sending you out to the file manager to see what you got.

History was the weakest surface once there was anything in it. File names were truncated with an ellipsis, and revealing a file meant finding a faint Show control that appeared on hover, which is easy to miss and easy to forget exists. Names now wrap in full and the name itself is the link that reveals the file, so the affordance is the thing you were already looking at. It also gained search, collapsible day groups, and a strikethrough on any file whose saved copy has since been moved or deleted, which needed a new method on the Go side because the webview cannot inspect the filesystem itself.

Then a privacy problem I only noticed by using it in front of someone: the history is the first thing on screen and it lists everything you have ever moved. There is now a control that masks file names, and turning it on also stops the search box matching against them, since a mask you can defeat by typing is not a mask.

There was no way to stop a transfer once it started, from either end. Cancel now exists on both sides, closes the connection cleanly, and deletes the partly written file rather than leaving something corrupt on disk with a plausible name.

My own machine turned up under Devices Nearby, because the app was answering its own discovery broadcast and listing the reply as a peer. Discovery now drops any result whose address is local and whose port matches the one it is already listening on.

The empty state was overclaiming. It read that files go straight to the other device with no cloud, verified on arrival, which quietly promises more than a checksum delivers. The word verified came out, and the copy now says the file is sent directly to your paired device and encrypted along the way, which is exactly what happens and nothing more.

The one that mattered most was the pairing screen overstating what it knew. It reported success the instant I clicked, before the other device had confirmed anything. The wire protocol already required a confirmation byte from both sides, so the code was right and the screen simply was not waiting for it. On a security step that is the worst place to be approximately correct, and success is now reported only after the full mutual exchange completes.

None of these were findable from a mockup and none were bugs in the ordinary sense. They are what a product looks like before anyone has actually lived in it.

Architecture

The transfer core is a Go package with no third party dependencies at all, cryptography included, and it is deliberately the only part of the product that can make that claim. The desktop shell cannot: Wails and its webview bring their own tree. Drawing that boundary explicitly is more honest than claiming purity for the whole app, and it is what lets one core back both a thin command line binary and the desktop app, sharing one protocol, one identity and one paired device store, so the two can pair with each other.

The seam between core and interface is an Observer interface: session start, file start, progress, file done, session end, and a separate callback for the sender waiting on the receiver. The command line implements it by printing a progress bar. The desktop app implements it by emitting events with a per transfer id so the frontend can correlate rows. The core knows nothing about Wails, and the contract states outright that callbacks run on the goroutine doing the transfer and must not block. Cancellation is an optional second interface the core type asserts for, so the command line, which does not need it, does not pay for it.

Several of the interface changes above were only possible because that seam was easy to widen. Showing a device name instead of an address, and tracking one bar across a whole folder, both meant adding fields to the event the core emits at session start. When the boundary is a small explicit interface rather than a tangle, a request from the design side is an afternoon rather than an argument.

Progress is throttled inside the writer that counts bytes, sitting in a multi writer alongside the file and the hasher, emitting at most once every hundred milliseconds with a guard letting the final event through regardless. Without it a fast local transfer floods the bridge to the frontend with events nobody can read.

The concurrency model is more conservative than people assume, deliberately. Files inside one transfer go strictly one after another down a single connection, no per file parallelism and no worker pool, because the link is the bottleneck rather than the scheduler and a sequential loop is far easier to reason about when something fails halfway. Concurrency exists only across separate incoming sessions, capped at sixteen using a buffered channel as a semaphore. At capacity the listener closes the excess connection rather than queueing it, because a sender refused immediately can retry while one parked in an invisible queue just looks broken.

On the wire, everything happens inside the mutual TLS session rather than in front of it, so even the protocol magic is read after the handshake. Then a single intent byte separating a transfer from a pairing, a file count, and per file a length prefixed header carrying name, size and a relative path so folder structure survives. Bodies are followed by their SHA-256 and a one byte acknowledgement.

Two things stated plainly rather than dressed up. There is no buffer tuning: bytes move through the standard library's default copy and no benchmark was run to justify a number, so no performance rationale is claimed for one. And atomicity is asymmetric. Identity keys and the paired device store are written to a temp file and renamed into place, owner readable only. Received files are not: they are written where they land, and the safety net is that a failure mid file deletes the partial rather than swapping a finished one into position.

Tradeoffs

Same network, always. There is no relay and no cloud fallback, so if two devices cannot see each other directly, Raven cannot help. That closes off a whole category of use in exchange for never having a copy of the file leave the room.

The six digit comparison is friction, on purpose. Trust on first use without a verification step is the part most tools quietly skip, and skipping it is what makes the pinning worth nothing.

A failed file ends the batch. Files already written stay on disk and the rest are never attempted, because a partial batch that silently continues is worse than one that stops and says so. The cost is real: no per file retry and no resume, so an interrupted file is deleted and sent again from the start.

Discovery is UDP broadcast rather than mDNS, and it is treated as untrusted: it suggests candidates and confers no trust at all, since connecting still has to pass the pinned fingerprint check. Broadcast is unreliable across firewalls and guest networks, which is exactly why adding a device by address sits beside it rather than hidden as a fallback.

Multi-select for folders is a native dialog limitation rather than a decision. The operating system will not let you pick several folders at once, so the picker became additive instead: add files, add a folder, build a list, remove individual items. The constraint is real and the workaround is visible rather than pretended away.

Both builds are unsigned, so first launch needs right-click and open on macOS, or more info and run anyway on Windows. That is a cost paid in first-run confusion rather than in a developer programme fee.

Outcomes

Released publicly for macOS as a universal binary and for Windows, with a thin standard library command line client alongside for scripted use. Mac to mac, windows to windows, or across.

Every file is checksummed with SHA-256 across the wire, so a transfer that completes is one you can trust. Real throughput is bound by the link and by how fast the disks at both ends can read and write, which on a local network puts it far beyond anything routed through the internet.

The hardening is in code rather than in the readme: metadata frames are bounded, files per session are capped, idle connections time out, concurrent receive sessions are limited, and filenames are stripped of control characters and Unicode bidirectional overrides so a name cannot disguise the extension it actually has.

Takeaways

Working is not a thing you get to assert. Raven shipped publicly twice on the strength of testing that covered the command line and the Go core and never the interface, and both times the people who downloaded it found out before I did. Drag and drop was broken outright in one build. So the rule now is that a change touching an interface is either driven through that exact interface or reported as unverified, and no build goes out on the second one.

Safety measures cause outages too. The bug that reached real users was a network timeout I added to be careful, which set a deadline covering reads and writes together and was never cleared after the handshake. It eventually elapsed mid-batch and killed the acknowledgement between files. The lesson is not to avoid timeouts, it is that anything added defensively deserves the same scrutiny as a feature, because it can only ever fail in production and never in the happy path you tested.

A lesson that lives only in my memory is one I get to learn twice. That deadline bug now has a regression test written to fail against the old behaviour. It costs an hour once and it is the only version of the lesson that survives me forgetting it.

Guessing at your own bug reports is still guessing. I reported that one to myself as a count problem, six or more files, because that is how it presented. It was a time problem: any batch running longer than the timeout. The symptom I could see and the cause were different shapes, which is the usual case and the reason to reproduce something before believing your own description of it.

The most valuable thing here was not the design work or the architecture. It was the fact that I use this every day, so the product got a class of feedback that no amount of planning generates. Almost everything in the improvements section came from that, and none of it would have come from a better spec.

Get to know more about my workflow

Thank you for reading all the way through. I appreciate the time. If this sounds like the kind of work you need, do get in touch. I’d love to hear what you’re building.

Get in touch