DEV Community

hiyoyo
hiyoyo

Posted on

I Built a Mac App to Instantly Sync Android Screenshots — Using Rust + Tauri

The Problem

If you use Android and Mac together, you've probably felt this pain.

You take a screenshot on your phone. Now you need it on your Mac. So you either wait for Google Photos to sync, DM it to yourself, or plug in a cable and dig through MTP folders.

None of these are broken. But doing it every single day gets old fast.

So I built Hiyoko Shot — a macOS menu bar app that transfers Android screenshots to your Mac the moment you take them.


What It Does

  • 📱 Auto-transfers screenshots from Android to Mac (USB or Wi-Fi)
  • 🖼 Gallery UI with preview and native drag & drop
  • ⚡ Low-overhead transfer via ADB exec-out
  • 🦀 Built with Rust (backend), React + Framer Motion (frontend), Tauri (framework)

The Interesting Parts

exec-out vs adb pull

The obvious approach for transferring files from Android is adb pull. But Hiyoko Shot uses adb exec-out instead.

adb exec-out screencap -p
Enter fullscreen mode Exit fullscreen mode

Here's why it matters:

Method Flow
adb pull Write to Android → Transfer → Read on Mac
exec-out Stream stdout directly to Mac

exec-out eliminates the intermediate write step entirely. The output streams directly to the host, cutting out the overhead of saving and re-reading a file on the Android side.


Polling for New Screenshots

To detect when a new screenshot is taken, the app polls /sdcard/DCIM/Screenshots/ on the Android device every 5 seconds via ADB.

// pseudocode
loop {
    let current_files = list_screenshots_via_adb(&device)?;
    let new_files = diff(&last_snapshot, &current_files);

    for file in new_files {
        transfer_file(&device, &file).await?;
    }

    last_snapshot = current_files;
    sleep(Duration::from_secs(5)).await;
}
Enter fullscreen mode Exit fullscreen mode

When the app is hidden in the background, the polling interval increases to reduce CPU and memory usage.


Displaying Images in Tauri: The Base64 Approach

When building the gallery UI in Tauri, passing a file path directly to <img src> didn't work reliably across environments. The fix was to encode images as Base64 Data URIs on the Rust side and pass them to React.

// Rust
let bytes = fs::read(&image_path)?;
let base64 = general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:image/jpeg;base64,{}", base64))
Enter fullscreen mode Exit fullscreen mode
// React
<img src={base64DataUri} alt="screenshot" />
Enter fullscreen mode Exit fullscreen mode

This works 100% reliably regardless of environment.


Native Drag & Drop

This is the feature I'm most proud of.

You can grab any image from the gallery and drop it directly into Slack, Discord, Finder — whatever. Tauri's startDrag API initiates a native macOS drag session, so it works exactly like dragging a file from Finder.

The difference in daily workflow is subtle but real. Instead of "find the file → drag it", it's just "drag from the app". One less step, every time.


Wi-Fi Mode

Once you pair via USB, the app can connect over Wi-Fi on the same network. It uses ADB over TCP/IP, so you don't need a cable for subsequent sessions.

adb tcpip 5555
adb connect 192.168.x.x:5555
Enter fullscreen mode Exit fullscreen mode

Stack Summary

Layer Tech
Backend Rust
Frontend React + Framer Motion
Framework Tauri
ADB exec-out / TCP/IP
Persistence tauri-plugin-store
Image conversion Rust image crate
UI Menu bar popover (dark mode native)

Wrapping Up

"Get Android screenshots onto a Mac" is a niche problem — but it's one that comes up every single day for anyone who lives between the two platforms.

Building this in Rust + Tauri was a great experience. Writing low-level ADB communication in Rust feels natural, and the performance headroom is comforting.

If you're an Android + Mac user, give it a try. It's a one-time purchase at $7.

Top comments (0)