Flit Devlog #3
Flit has one job: move a thing from one device to another without making you think about it. After devlog #2 it could already do the hard part — receive in real time over SSE, no refresh. But there was still a dumb gap at the end: a link would land, and I’d reach for the mouse, click the card, hit copy. For a project whose whole personality is “frictionless,” that last inch was embarrassing. This entry kills it: paste-on-arrival, and drops that take themselves out when they’re done.
Paste before you ask for it
The plan felt trivial. The SSE stream already fires an item event whenever something new shows up, so: listen for it, grab the newest text, drop it on the clipboard. No button. You copy on your phone, tab to your laptop, and it’s already under Ctrl+V.
source.addEventListener("item", async () => {
const items = await fetch("/api/items").then((r) => r.json());
if (items[0]?.text) await navigator.clipboard.writeText(items[0].text);
});
Ship it, right?
…except the browser quietly says no
It didn’t work — and the cruel part is how. No crash, no console error, the text just never landed. The only tell was a buried NotAllowedError.
Here’s what nobody warns you about: clipboard.writeText() needs transient user activation. The browser only allows a clipboard write in the short window right after a real gesture — a click, a keypress. A message arriving over SSE is not a gesture. To the browser, a background script grabbing your clipboard is exactly what it should block. Fair.
This also explained a bug that was driving me up the wall: auto-copy “only worked after toggling it off and on.” Of course — the toggle click itself was the gesture. So the honest fix was to lean in: the auto-copy switch IS the gesture. You arm it with a click, and the browser trusts the page to keep copying. Not magic — just asking permission in the only language the browser accepts.
Drops that clean up after themselves
The other half of frictionless is never having to remember to delete things. A drop inbox that fills up forever is just a junk drawer with a URL.
So every item gets an expires timestamp at creation, and a reaper task wakes up every 30 seconds to sweep anything past it:
let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
tick.tick().await;
let t = now();
state.items.lock().unwrap()
.retain(|_, it| it.expires == 0 || it.expires > t);
}
I chose an active reaper over lazy “check on read” expiry on purpose: lazy expiry lets stale items linger until someone looks, and the list can briefly lie about what’s still alive. A background sweep keeps memory and the UI honest. Default is one hour (FLIT_TTL_SECS=3600); 0 means keep forever.
Where Flit stands
The three quality-of-life wins are all in:
- Real-time receive — drops appear instantly (devlog #2)
- Clipboard-direct — text and links land on your clipboard
- Auto-expiry — drops delete themselves on a TTL
And it’s still one binary you can curl at like a caveman if you feel like it.
Next up
It works great on my machine — the most dangerous sentence in software. Step 5 makes it real for other machines: a proper flit CLI (plus a PowerShell sibling), an auth layer so it isn’t wide open the second it leaves localhost, and a release workflow that ships prebuilt binaries for all three platforms. See you in #4.
Comments 0
No comments yet. Be the first!
Sign in to join the conversation.