<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jephter</title>
    <description>The latest articles on DEV Community by Jephter (@iamjephter).</description>
    <link>https://dev.to/iamjephter</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3684933%2Fe38c6d29-1971-410b-af93-cc1dbbff8b76.png</url>
      <title>DEV Community: Jephter</title>
      <link>https://dev.to/iamjephter</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iamjephter"/>
    <language>en</language>
    <item>
      <title>Architecting a Blind Relay: E2EE Clipboard Sync with Rust and Tauri</title>
      <dc:creator>Jephter</dc:creator>
      <pubDate>Wed, 29 Apr 2026 12:19:09 +0000</pubDate>
      <link>https://dev.to/iamjephter/building-a-blind-relay-in-rust-with-tauri-at-the-edge-57gp</link>
      <guid>https://dev.to/iamjephter/building-a-blind-relay-in-rust-with-tauri-at-the-edge-57gp</guid>
      <description>&lt;p&gt;&lt;em&gt;A blind relay in Rust for encrypted clipboard sync: the client owns the key, and the server only moves ciphertext.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem &amp;amp; Why Clipboard Sync Needs a Blind Relay
&lt;/h2&gt;

&lt;p&gt;Most clipboard sync tools have a quiet design flaw: the server can read everything—and most people never notice.&lt;/p&gt;

&lt;p&gt;Echo is my cross-device clipboard sync project. Copy text on one device, paste it on another. The hard part is not moving the text. The hard part is moving it without teaching the server how to read it. Echo is end-to-end encrypted clipboard sync built on a blind relay: Tauri sits at the native edge, the Rust/Axum backend handles auth and delivery, and the backend can move data without becoming part of the trust model.&lt;/p&gt;

&lt;p&gt;In plain terms, a blind relay is a courier. It can check who you are, carry the package, and deliver it to the right place. It cannot open the package. In Echo, the package is ciphertext, and only the devices hold the key.&lt;/p&gt;

&lt;p&gt;I built Echo this way because I did not want the usual trade. Not “encrypted in transit” with plaintext sitting in the middle. Not “encrypted at rest” with a backend that can still inspect what it relays. In Echo, “blind relay” means the server can authenticate the user, enforce rate limits, store ciphertext, send it to the right devices, and wake sleeping clients when needed. It does not get a decryption path.&lt;/p&gt;

&lt;p&gt;That sounds abstract until you look at what actually ends up on a clipboard. SSH commands with temporary credentials. Customer data copied out of an internal tool. Admin URLs with tokens embedded in query params. One-time codes. Half-finished production queries. The clipboard is full of data that is sensitive precisely because it is short-lived and easy to treat casually.&lt;/p&gt;

&lt;p&gt;The first time I felt this sharply was not during some security review. It was while moving a short-lived command between machines and realizing the fastest path was still a chat window. That is the normal failure mode. Not a dramatic breach. Just a convenient tool inheriting data it should never have seen.&lt;/p&gt;

&lt;p&gt;What I built is simple to describe and strict in practice. Echo watches the clipboard on one device, encrypts the payload on the client with XChaCha20-Poly1305, sends ciphertext and a nonce through an Axum WebSocket relay, then decrypts only on the receiving device. The important choices are the typed WebSocket protocol, a one-owner WebSocket sink, bounded queues, and client-side encryption.&lt;/p&gt;

&lt;p&gt;The result is a ciphertext relay, not a server-side encryption story. That distinction is the article.&lt;/p&gt;

&lt;p&gt;Once I decided the server would be blind, the architecture got much simpler. The client owns the key. The client encrypts before the network. The backend handles delivery, ordering, and backpressure. Rust ended up being the right language because the hard parts were ownership, protocol design, concurrency, and removing bad states before they spread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rust, Tauri, and the Blind Relay Architecture
&lt;/h2&gt;

&lt;p&gt;Echo has two distinct halves and one hard boundary between them.&lt;/p&gt;

&lt;p&gt;The backend is an Axum service on Tokio with SQLx behind it. It owns authentication, the typed WebSocket protocol, live fan-out, bounded history, and push-token persistence. The client is a Tauri shell with a React app inside it. Tauri handles the native clipboard boundary and local persistence. The React layer handles keys, pairing, reconnect behavior, encryption, and UI state.&lt;/p&gt;

&lt;p&gt;I do not think of Echo as a generic Tauri clipboard sync app. The Rust Tauri architecture matters because the trust boundary is split cleanly: native clipboard work at the edge, ciphertext coordination in the backend.&lt;/p&gt;

&lt;p&gt;At the container level, the system is just a left-to-right relay with two side channels: durable ciphertext history and push wake-ups for sleeping devices.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fayz9ptawxfnxm9nwtuq9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fayz9ptawxfnxm9nwtuq9.png" alt="Mermaid-style architecture diagram for Echo, an encrypted clipboard sync system built with Rust, Tauri, Axum WebSockets, client-side encryption, and a blind relay server that only forwards ciphertext" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AppState&lt;/code&gt; is intentionally thin. It coordinates three smaller subsystems instead of accumulating behavior until nobody trusts it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SyncState&lt;/code&gt; owns live sessions, broadcast channels, and the in-memory history cache.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RateLimiter&lt;/code&gt; owns admission control.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PushManager&lt;/code&gt; owns push-token state and delivery limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I kept the hot path in memory on purpose. Sync services always have hot state. Hiding that behind the database does not make the system simpler. It just makes latency worse and makes it harder to know which copy of the state is in charge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Design Decisions
&lt;/h2&gt;

&lt;p&gt;I started with the trust boundary, not the stack.&lt;/p&gt;

&lt;p&gt;Every real clipboard payload is encrypted on the client before it crosses the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NONCE_BYTES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;xchacha20poly1305&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
    &lt;span class="na"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used XChaCha20-Poly1305 because I wanted a large nonce space and a boring API. That is the right kind of boring. The key stays on the device. Pairing moves it directly between devices through a QR or deep-link flow. The backend never gets a decryption path because I did not want one to exist.&lt;/p&gt;

&lt;p&gt;If you are newer to this language: &lt;code&gt;plaintext&lt;/code&gt; is the readable clipboard text, &lt;code&gt;ciphertext&lt;/code&gt; is the encrypted output, and the &lt;code&gt;nonce&lt;/code&gt; is a unique value used with the key for one encryption operation. The server stores and relays the ciphertext and nonce. It never gets the key.&lt;/p&gt;

&lt;p&gt;The second design decision was to stop pretending the protocol was “basically one message type.” The early version leaned too hard on strings and I knew it was wrong while writing it. A sync system already has enough moving parts. It does not need control flow and data flow squeezed into the same shape because it feels convenient in the first commit.&lt;/p&gt;

&lt;p&gt;A typed WebSocket protocol means different kinds of messages have different shapes. A handshake is not a clipboard payload. A presence event is not an error. That sounds obvious, but making it explicit removes a lot of guessing from the code.&lt;/p&gt;

&lt;p&gt;The third decision was about ownership. I rejected &lt;code&gt;Arc&amp;lt;Mutex&amp;lt;_&amp;gt;&amp;gt;&lt;/code&gt; around the WebSocket sink almost immediately. In async Rust that pattern usually turns into hidden coordination cost. The sink has one owner. Every other outbound path goes through a bounded channel. The lifecycle becomes obvious. Backpressure becomes explicit. Shutdown stops depending on unwritten rules.&lt;/p&gt;

&lt;p&gt;The one-owner sink rule is simple: only one task writes to the socket. Everyone else sends messages to that task. That keeps the async write path boring, which is exactly what I want.&lt;/p&gt;

&lt;p&gt;The fourth decision was to keep Tauri narrow. I wanted it exactly where it belongs: at the native edge, where clipboard behavior, local secret storage, and mobile bridge behavior are platform concerns. Everything above that line stays in app code.&lt;/p&gt;

&lt;p&gt;One thing I would add next is protocol versioning. The current wire model is finally strict enough that version negotiation would be straightforward. Today the client and server still move in lockstep. I can live with that at this stage. I would not call it finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep Dives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The protocol only got sane once I modeled it as an ADT
&lt;/h3&gt;

&lt;p&gt;Rust gives you tagged enums for a reason. If a protocol has multiple states with different meanings, model them that way.&lt;/p&gt;

&lt;p&gt;An ADT here is just a Rust enum used to model the possible message types. Instead of carrying one loose object around and asking “what kind of thing is this?”, the compiler can force the code to handle each variant directly.&lt;/p&gt;

&lt;p&gt;Echo now uses tagged enums on the wire:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(tag&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;rename_all&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"snake_case"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;ClientMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Handshake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HandshakeMessage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Clipboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClipboardMessage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(tag&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;rename_all&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"snake_case"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;ServerMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Clipboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClipboardFrame&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Presence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PresenceMessage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProtocolError&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one decision removed a surprising amount of defensive code. The sync loop no longer has to guess meaning from special strings or overloaded fields. If a frame parses as &lt;code&gt;ClientMessage::Clipboard&lt;/code&gt;, the code already knows it is the encrypted payload path. If it parses as &lt;code&gt;Handshake&lt;/code&gt;, it is control flow. That is exactly the kind of separation I want the compiler enforcing.&lt;/p&gt;

&lt;p&gt;The frontend mirrors the same model in &lt;code&gt;desktop/src/protocol.ts&lt;/code&gt;, which matters more than people think. Client and server bugs get much rarer when both sides are forced to agree on the shape of the conversation instead of keeping two sets of assumptions in sync by luck.&lt;/p&gt;

&lt;h3&gt;
  
  
  Owning the WebSocket Sink in Async Rust
&lt;/h3&gt;

&lt;p&gt;This is the part of the backend I would defend hardest in review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="nf"&gt;.split&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write_rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WRITER_CHANNEL_CAPACITY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;writer_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;run_writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write_rx&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I gave the sink to one task and never looked back.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;tokio::spawn&lt;/code&gt; call also hides a Rust rule that is doing real architectural work for me: spawned futures must be &lt;code&gt;Send + 'static&lt;/code&gt;. In practice, that means the task owns what it needs, can move across worker threads safely, and does not borrow some stack frame that will disappear under it. That is exactly what I want for connection handling.&lt;/p&gt;

&lt;p&gt;That matters even if you are not deep into async Rust yet. A connection task should not depend on borrowed local state from a handler that may already be gone. Owning the data makes shutdown and cancellation much easier to reason about.&lt;/p&gt;

&lt;p&gt;Once the sink belongs to &lt;code&gt;run_writer&lt;/code&gt;, nobody else gets to send directly. History replay goes through the channel. Presence fan-out goes through the channel. Error frames go through the channel. Ping frames go through the channel. The write path is now one thing.&lt;/p&gt;

&lt;p&gt;The teardown path gets simpler too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;select!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;send_task&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;recv_task&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ping_task&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;writer_task&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;send_task&lt;/span&gt;&lt;span class="nf"&gt;.abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;recv_task&lt;/span&gt;&lt;span class="nf"&gt;.abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;ping_task&lt;/span&gt;&lt;span class="nf"&gt;.abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;writer_task&lt;/span&gt;&lt;span class="nf"&gt;.abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That code is blunt on purpose. Sync systems do not usually fail in interesting ways. They fail in reconnect logic, stale tasks, and half-closed connections. I wanted the lifecycle to be clear enough that you could trace it in your head without inventing missing rules.&lt;/p&gt;

&lt;p&gt;I also make the server authoritative for device identity after the handshake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;clipboard_msg&lt;/span&gt;&lt;span class="py"&gt;.device_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;clipboard_msg&lt;/span&gt;&lt;span class="py"&gt;.device_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device_name&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line closes a subtle but ugly class of bugs. A client does not get to authenticate once and then improvise a different identity mid-session. The server binds identity to the connection and stamps every clipboard frame with the validated values.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tauri is where I stop clipboard echo loops at the edge
&lt;/h3&gt;

&lt;p&gt;Cross-device clipboard sync has a boring failure mode: a remote write lands in the local clipboard, the local watcher sees it as a fresh local update, and the system starts rebroadcasting its own output.&lt;/p&gt;

&lt;p&gt;I kill that loop in native code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"clipboard-remote-write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="nf"&gt;.payload&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ignored_hash_clone&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the polling loop ignores that exact value once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ignored_hash&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;guard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_hash&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;last_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of the few places where &lt;code&gt;Arc&amp;lt;Mutex&amp;lt;_&amp;gt;&amp;gt;&lt;/code&gt; is not just acceptable, it is the right tool. The listener is synchronous. The critical section is tiny. There is no &lt;code&gt;.await&lt;/code&gt; near the lock. I do not need an async primitive. I need one shared piece of state and a fixed rule.&lt;/p&gt;

&lt;p&gt;I prefer this to debounce-style heuristics because timing lies. Clipboard APIs are noisy. Focus changes are noisy. “Probably the same event” is not an invariant. “Ignore the next clipboard value with this exact hash” is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Type System Enforces
&lt;/h2&gt;

&lt;p&gt;The best thing Rust bought me here was not speed. It was removing states I never wanted the system to represent.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DeviceId&lt;/code&gt;, &lt;code&gt;DeviceName&lt;/code&gt;, and &lt;code&gt;PushToken&lt;/code&gt; are validated newtypes. That pushes string validation to the edge. Once a handler or state method takes &lt;code&gt;&amp;amp;DeviceId&lt;/code&gt;, I already know the value is non-empty and within bounds. That check is not scattered through the core logic.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthUser&lt;/code&gt; pushes authentication into the handler signature. If a route accepts &lt;code&gt;AuthUser&lt;/code&gt;, Axum has already validated the bearer token and parsed &lt;code&gt;sub&lt;/code&gt; into a &lt;code&gt;Uuid&lt;/code&gt;. The unauthenticated state is not something the handler can forget to deal with because it never reaches that point.&lt;/p&gt;

&lt;p&gt;The rate limiter uses an enum instead of a boolean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;IntervalPolicy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Enforce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Ignore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small example, but I like it because it is honest. &lt;code&gt;true&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt; say nothing. &lt;code&gt;Enforce&lt;/code&gt; and &lt;code&gt;Ignore&lt;/code&gt; make the call say what it means.&lt;/p&gt;

&lt;p&gt;The protocol is another example. Presence, handshake, clipboard, and error frames are separate types because they are separate concepts. The compiler keeps those paths apart at zero runtime cost. That is exactly the kind of leverage I want in a system that spends its life moving state between machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance, Backpressure, and Bounded State
&lt;/h2&gt;

&lt;p&gt;Echo is built around limits because unbounded behavior is where small systems quietly become unreliable.&lt;/p&gt;

&lt;p&gt;History in memory is capped at 50 messages per user. Durable history is trimmed to the newest 100 rows. The writer channel is capped at 64 messages. The client-side offline queue is capped at 200. Broadcast capacity scales by device count and clamps between 50 and 500.&lt;/p&gt;

&lt;p&gt;Those are not random constants I sprinkled in at the end. They are the system deciding where backpressure should show up instead of pretending it can absorb infinite demand.&lt;/p&gt;

&lt;p&gt;The rate limiter is also laid out to avoid extra contention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Clone,&lt;/span&gt; &lt;span class="nd"&gt;Default)]&lt;/span&gt;
&lt;span class="nd"&gt;#[repr(align(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="nd"&gt;))]&lt;/span&gt; &lt;span class="c1"&gt;// prevent false sharing between DashMap shard entries&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;RateLimitState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;last_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;message_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;#[repr(align(64))]&lt;/code&gt; is there because this state is hot and concurrent. I am not interested in cargo-cult micro-optimizations, but this one is cheap and justified.&lt;/p&gt;

&lt;p&gt;I also accepted owned &lt;code&gt;String&lt;/code&gt; payloads and real clones on fan-out because clipboard messages are usually small and the simpler ownership model matters more than shaving that path too early. The bigger performance win is structural: the system appends to memory and fans out first, then writes to Postgres behind the hot path. Delivery does not wait for the database.&lt;/p&gt;

&lt;p&gt;The repo includes Criterion benches for broadcast and rate limiting. I care about them, but the larger performance story is simpler than any benchmark graph: bounded queues, no lock-across-await paths, one-owner sinks, and CPU-heavy work kept off the async scheduler when it belongs somewhere else.&lt;/p&gt;

&lt;p&gt;This is not a sketch. The repo has benches for the hot paths, the protocol is typed end to end, and the backend, frontend, and native builds all pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The most important decision in Echo was refusing to let the server join the trust model.&lt;/p&gt;

&lt;p&gt;Once that was fixed, the rest of the design got much clearer. The client owns secrets. The server owns coordination. Rust enforces the protocol shape and the ownership model. Tauri handles the native edge where web code has no business bluffing.&lt;/p&gt;

&lt;p&gt;That is the takeaway I would carry into any system like this. If your relay can read the data, it is not a relay anymore. It is the problem.&lt;/p&gt;

&lt;p&gt;The code is public, including the typed protocol, WebSocket handler, Tauri clipboard edge, and architecture diagram: &lt;a href="https://github.com/jephter-olamiposi/echo" rel="noopener noreferrer"&gt;github.com/jephter-olamiposi/echo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>security</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
