<?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: hiyoyo</title>
    <description>The latest articles on DEV Community by hiyoyo (@hiyoyok).</description>
    <link>https://dev.to/hiyoyok</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%2F3851832%2Fa2762ba1-e687-4ae9-901d-245b96cf95d6.jpg</url>
      <title>DEV Community: hiyoyo</title>
      <link>https://dev.to/hiyoyok</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hiyoyok"/>
    <language>en</language>
    <item>
      <title>Adding AI Error Analysis to a macOS Menu Bar App with Gemini API</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Mon, 20 Apr 2026 12:38:41 +0000</pubDate>
      <link>https://dev.to/hiyoyok/adding-ai-error-analysis-to-a-macos-menu-bar-app-with-gemini-api-4e5d</link>
      <guid>https://dev.to/hiyoyok/adding-ai-error-analysis-to-a-macos-menu-bar-app-with-gemini-api-4e5d</guid>
      <description>&lt;p&gt;When a monitored script fails, most tools just show you a red icon.&lt;br&gt;
HiyokoBar does something better: it automatically sends the error output to Gemini AI and displays a diagnosis — no copy-pasting logs, no context switching.&lt;br&gt;
Here's how I built it.&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%2Focdzv16u05ob1d049m0n.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%2Focdzv16u05ob1d049m0n.png" alt=" " width="694" height="1080"&gt;&lt;/a&gt;&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%2F7xtf9a340qmtqk3ly13x.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%2F7xtf9a340qmtqk3ly13x.png" alt=" " width="714" height="1142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Problem&lt;br&gt;
Engineers spend a surprising amount of time doing this:&lt;/p&gt;

&lt;p&gt;Script fails&lt;br&gt;
Copy error output&lt;br&gt;
Open browser&lt;br&gt;
Paste into ChatGPT/Claude/Gemini&lt;br&gt;
Read diagnosis&lt;br&gt;
Switch back to terminal&lt;/p&gt;

&lt;p&gt;This is 2026. The AI should come to you.&lt;/p&gt;

&lt;p&gt;Architecture&lt;br&gt;
Each monitor card in HiyokoBar has an analyze_errors toggle. When enabled:&lt;/p&gt;

&lt;p&gt;Script runs via std::process::Command&lt;br&gt;
Non-zero exit code detected&lt;br&gt;
stdout + stderr sent to Gemini API&lt;br&gt;
Response displayed as "AI Insight" panel in golden color&lt;/p&gt;

&lt;p&gt;The entire flow is automatic. Zero user interaction required.&lt;/p&gt;

&lt;p&gt;Implementation&lt;br&gt;
The Gemini API Call&lt;br&gt;
rustuse reqwest::Client;&lt;br&gt;
use serde_json::json;&lt;/p&gt;

&lt;p&gt;pub async fn analyze_error(&lt;br&gt;
    error_output: &amp;amp;str,&lt;br&gt;
    api_key: &amp;amp;str,&lt;br&gt;
) -&amp;gt; Result&amp;gt; {&lt;br&gt;
    let prompt = format!(&lt;br&gt;
        "You are a senior engineer analyzing a script error. \&lt;br&gt;
        Provide a concise diagnosis (2-3 sentences max) and the most likely fix.\n\n\&lt;br&gt;
        Error output:\n{}",&lt;br&gt;
        error_output&lt;br&gt;
    );&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let client = Client::new();
let response = client
    .post("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent")
    .header("X-goog-api-key", api_key)
    .json(&amp;amp;json!({
        "contents": [{
            "parts": [{"text": prompt}]
        }],
        "generationConfig": {
            "maxOutputTokens": 256,
            "temperature": 0.3
        }
    }))
    .send()
    .await?;

let data: serde_json::Value = response.json().await?;
let text = data["candidates"][0]["content"]["parts"][0]["text"]
    .as_str()
    .unwrap_or("Analysis unavailable")
    .to_string();

Ok(text)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Key decisions:&lt;/p&gt;

&lt;p&gt;gemini-2.5-flash for speed and cost efficiency&lt;br&gt;
maxOutputTokens: 256 — engineers want diagnosis, not essays&lt;br&gt;
temperature: 0.3 — factual, not creative&lt;/p&gt;

&lt;p&gt;Triggering the Analysis&lt;br&gt;
rustasync fn run_monitor(monitor: &amp;amp;Monitor, api_key: Option&amp;lt;&amp;amp;str&amp;gt;) -&amp;gt; MonitorResult {&lt;br&gt;
    let output = Command::new(&amp;amp;monitor.command)&lt;br&gt;
        .args(&amp;amp;monitor.args)&lt;br&gt;
        .output()&lt;br&gt;
        .await;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;match output {
    Ok(out) if out.status.success() =&amp;gt; MonitorResult::Success(
        String::from_utf8_lossy(&amp;amp;out.stdout).to_string()
    ),
    Ok(out) =&amp;gt; {
        let error_text = format!(
            "stdout: {}\nstderr: {}",
            String::from_utf8_lossy(&amp;amp;out.stdout),
            String::from_utf8_lossy(&amp;amp;out.stderr)
        );

        // Only analyze if AI is enabled and key is available
        let insight = if monitor.analyze_errors {
            if let Some(key) = api_key {
                analyze_error(&amp;amp;error_text, key).await.ok()
            } else {
                None
            }
        } else {
            None
        };

        MonitorResult::Error { error_text, insight }
    }
    Err(e) =&amp;gt; MonitorResult::Error {
        error_text: e.to_string(),
        insight: None,
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
The AI Insight UI&lt;br&gt;
On the React side, the insight panel appears with a golden glow when an AI diagnosis is available:&lt;br&gt;
tsx{result.insight &amp;amp;&amp;amp; (&lt;br&gt;
  &amp;lt;motion.div&lt;br&gt;
    initial={{ opacity: 0, y: -8 }}&lt;br&gt;
    animate={{ opacity: 1, y: 0 }}&lt;br&gt;
    className="mt-2 p-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10"&lt;/p&gt;

&lt;blockquote&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div className="flex items-center gap-2 mb-1"&amp;gt;
  &amp;lt;Sparkles className="w-3 h-3 text-yellow-400" /&amp;gt;
  &amp;lt;span className="text-xs font-bold text-yellow-400"&amp;gt;AI INSIGHT&amp;lt;/span&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;p className="text-xs text-yellow-200/80"&amp;gt;{result.insight}&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br&gt;
)}&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Storing the API Key Securely&lt;br&gt;
Never put API keys in config files. I use macOS Keychain via the keyring crate:&lt;br&gt;
rustuse keyring::Entry;&lt;/p&gt;

&lt;p&gt;pub fn save_gemini_key(key: &amp;amp;str) -&amp;gt; keyring::Result&amp;lt;()&amp;gt; {&lt;br&gt;
    Entry::new("hiyoko-bar", "gemini-api-key")?.set_password(key)&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;pub fn load_gemini_key() -&amp;gt; keyring::Result {&lt;br&gt;
    Entry::new("hiyoko-bar", "gemini-api-key")?.get_password()&lt;br&gt;
}&lt;br&gt;
The key lives in Keychain, never touches disk as plaintext.&lt;/p&gt;

&lt;p&gt;Result&lt;br&gt;
The AI Insight feature turns HiyokoBar from a "pretty dashboard" into an actual engineering tool. When your Docker container crashes at 2am, you want a diagnosis in your menu bar — not another tab to open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HiyokoBar&lt;/strong&gt;: &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🇯🇵 日本語版: &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar_jp" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar_jp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🐤 Launched on Product Hunt today — would love your support!&lt;br&gt;
&lt;a href="https://www.producthunt.com/products/hiyokobar" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/hiyokobar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;X: &lt;a href="https://x.com/hiyoyok" rel="noopener noreferrer"&gt;@hiyoyoko&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>tauri</category>
      <category>gemini</category>
    </item>
    <item>
      <title>I Just Launched HiyokoBar on Product Hunt</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Mon, 20 Apr 2026 07:33:58 +0000</pubDate>
      <link>https://dev.to/hiyoyok/i-just-launched-hiyokobar-on-product-hunt-45if</link>
      <guid>https://dev.to/hiyoyok/i-just-launched-hiyokobar-on-product-hunt-45if</guid>
      <description>&lt;p&gt;I was alt-tabbing 10 times a morning just to check Docker status and GitHub PRs.&lt;/p&gt;

&lt;p&gt;This is 2026. That information should come to you.&lt;/p&gt;

&lt;p&gt;So I built HiyokoBar — an always-on dashboard that lives in your macOS menu bar. Set up any shell script to run on a schedule. Results surface as glass cards, right where you're already looking. Error detected? Gemini AI diagnoses it instantly. Full GUI, zero JSON required.&lt;/p&gt;

&lt;p&gt;Built with Rust + Tauri 2.0. Shipped in one day by reusing architecture from my Hiyoko app family.&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%2Ftcnwil2nw9ep6v971h94.gif" 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%2Ftcnwil2nw9ep6v971h94.gif" alt=" " width="300" height="496"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;🛒 Get it on Gumroad:&lt;br&gt;
🌏 &lt;br&gt;
English: &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar&lt;/a&gt;&lt;br&gt;
🇯🇵Japanese:&lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar_jp" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar_jp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🚀 Launched on Product Hunt today:&lt;br&gt;
&lt;a href="https://www.producthunt.com/products/hiyokobar" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/hiyokobar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Would love your feedback! 🐤💐&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>tauri</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built and Shipped a $39 macOS App in One Day (Here's How)</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Mon, 20 Apr 2026 07:16:09 +0000</pubDate>
      <link>https://dev.to/hiyoyok/i-built-and-shipped-a-39-macos-app-in-one-day-heres-how-5e80</link>
      <guid>https://dev.to/hiyoyok/i-built-and-shipped-a-39-macos-app-in-one-day-heres-how-5e80</guid>
      <description>&lt;p&gt;Yesterday I had an idea. Today it's on Gumroad making money.&lt;br&gt;
Here's exactly how I shipped HiyokoBar — a menu bar HUD for engineers — in a single day.&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%2Ftnjxm45fgn30nccpooeg.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%2Ftnjxm45fgn30nccpooeg.png" alt=" " width="694" height="1080"&gt;&lt;/a&gt;&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%2Fy0v7081ia7zlefri4946.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%2Fy0v7081ia7zlefri4946.png" alt=" " width="714" height="1142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Idea&lt;br&gt;
I was constantly alt-tabbing to check Docker status, GitHub PRs, server health.&lt;br&gt;
I wanted something that pushes information to me, not something I have to pull from.&lt;br&gt;
Raycast is great but it's Pull-type. You invoke it when you need it.&lt;br&gt;
I wanted a HUD that's always there — like a cockpit instrument panel for my Mac.&lt;/p&gt;

&lt;p&gt;The Unfair Advantage: Code Reuse&lt;br&gt;
I didn't start from zero. I have a portfolio of macOS utility apps built with Rust + Tauri 2.0, and I ruthlessly reused everything I could:&lt;br&gt;
ComponentSourceMenu bar skeletonHiyokoHelper (internal app)Keychain storageHiyokoKitGumroad license authHiyokoShotParallel executionHiyokoAutoSyncGemini AI integrationHiyokoLogcat&lt;br&gt;
The menu bar app architecture — tray icon, click to show/hide, window positioning — was already battle-tested. I just dropped in new features.&lt;br&gt;
This is why building a product suite matters more than one-off apps.&lt;/p&gt;

&lt;p&gt;Timeline&lt;br&gt;
Morning&lt;/p&gt;

&lt;p&gt;Copied HiyokoHelper as the base&lt;br&gt;
Replaced window positioning with tauri-plugin-positioner&lt;br&gt;
Got the HUD dropping down from the menu bar icon&lt;/p&gt;

&lt;p&gt;Midday&lt;/p&gt;

&lt;p&gt;Built the shell script execution engine in Rust&lt;br&gt;
Card UI in React with Framer Motion animations&lt;br&gt;
Polling with random jitter to avoid CPU spikes&lt;/p&gt;

&lt;p&gt;Afternoon&lt;/p&gt;

&lt;p&gt;Gemini AI error analysis (ported from HiyokoLogcat)&lt;br&gt;
GUI settings editor — no JSON required&lt;br&gt;
Drag &amp;amp; drop card reordering&lt;br&gt;
Real-time countdown timers&lt;/p&gt;

&lt;p&gt;Evening&lt;/p&gt;

&lt;p&gt;Gumroad license auth (ported from HiyokoShot)&lt;br&gt;
macOS Keychain for API key storage&lt;br&gt;
OWASP security audit (caught a critical A02 bug — API keys were stored in plain JSON instead of Keychain)&lt;br&gt;
i18n (Japanese / English)&lt;br&gt;
Gumroad listing, README, pricing&lt;/p&gt;

&lt;p&gt;Total: ~8 hours&lt;/p&gt;

&lt;p&gt;The Security Audit Saved Me&lt;br&gt;
Before shipping, I ran through OWASP Top 10 for native apps.&lt;br&gt;
Found a critical bug: despite the design doc saying "store in Keychain," the actual implementation was saving API keys in plain JSON.&lt;br&gt;
A quick audit before shipping caught what code review missed.&lt;br&gt;
Always check OWASP before you ship. Even for "simple" apps.&lt;/p&gt;

&lt;p&gt;Pricing: $39 One-Time&lt;br&gt;
I originally planned $29. Looked at what I built and charged $39.&lt;br&gt;
The target is senior engineers. They don't blink at $39 for a tool they'll use every day. Underpricing signals low quality to this audience.&lt;/p&gt;

&lt;p&gt;What's Next&lt;/p&gt;

&lt;p&gt;Product Hunt launch this week&lt;br&gt;
v3.0 ideas: community recipe store, macOS notifications, multi-AI support&lt;/p&gt;

&lt;p&gt;Try It&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HiyokoBar&lt;/strong&gt;: &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🇯🇵 日本語版: &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar_jp" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar_jp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🐤 Launched on Product Hunt today — would love your support!&lt;br&gt;
&lt;a href="https://www.producthunt.com/products/hiyokobar" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/hiyokobar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;X: &lt;a href="https://x.com/hiyoyok" rel="noopener noreferrer"&gt;@hiyoyoko&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>tauri</category>
      <category>gemini</category>
    </item>
    <item>
      <title>How I Built a macOS Menu Bar HUD with Rust + Tauri 2.0</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Mon, 20 Apr 2026 05:45:12 +0000</pubDate>
      <link>https://dev.to/hiyoyok/how-i-built-a-macos-menu-bar-hud-with-rust-tauri-20-pij</link>
      <guid>https://dev.to/hiyoyok/how-i-built-a-macos-menu-bar-hud-with-rust-tauri-20-pij</guid>
      <description>&lt;p&gt;I recently built HiyokoBar — a push-type HUD for engineers that lives in the macOS menu bar and runs shell scripts on a schedule, displaying results as beautiful cards.&lt;br&gt;
Here's a technical breakdown of the interesting parts.&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%2Ftkeaxwlg4rqsr8g69tvq.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%2Ftkeaxwlg4rqsr8g69tvq.png" alt=" " width="694" height="1080"&gt;&lt;/a&gt;&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%2Fbihl06vf32588glr2lr5.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%2Fbihl06vf32588glr2lr5.png" alt=" " width="714" height="1142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tech Stack&lt;/p&gt;

&lt;p&gt;Backend: Rust / Tauri 2.0 / Tokio / Reqwest&lt;br&gt;
Frontend: React + TypeScript + Tailwind CSS + Framer Motion&lt;br&gt;
AI: Google Gemini API&lt;br&gt;
Auth: Gumroad License API + SHA256 + macOS Keychain&lt;/p&gt;

&lt;p&gt;Menu Bar App with Tauri 2.0&lt;br&gt;
Tauri 2.0 makes menu bar apps surprisingly straightforward. The key is configuring the window to behave like a native HUD.&lt;br&gt;
rust// tauri.conf.json&lt;br&gt;
{&lt;br&gt;
  "app": {&lt;br&gt;
    "windows": [{&lt;br&gt;
      "visible": false,&lt;br&gt;
      "decorations": false,&lt;br&gt;
      "alwaysOnTop": true,&lt;br&gt;
      "skipTaskbar": true&lt;br&gt;
    }]&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
For positioning the window directly below the tray icon, I used tauri-plugin-positioner:&lt;br&gt;
rustuse tauri_plugin_positioner::{Position, WindowExt};&lt;/p&gt;

&lt;p&gt;fn show_window(app: &amp;amp;AppHandle) {&lt;br&gt;
    let window = app.get_webview_window("main").unwrap();&lt;br&gt;
    let _ = window.move_window(Position::TrayBottomCenter);&lt;br&gt;
    window.show().unwrap();&lt;br&gt;
    window.set_focus().unwrap();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Shell Script Execution with Jitter&lt;br&gt;
Running multiple scripts simultaneously causes CPU spikes. I solved this by adding random jitter to each script's execution offset:&lt;br&gt;
rustuse tokio::time::{sleep, Duration};&lt;br&gt;
use rand::Rng;&lt;/p&gt;

&lt;p&gt;async fn start_monitor(monitor: Monitor, tx: Sender) {&lt;br&gt;
    let offset = rand::thread_rng().gen_range(0..10);&lt;br&gt;
    sleep(Duration::from_secs(offset)).await;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;loop {
    let result = execute_command(&amp;amp;monitor).await;
    tx.send(result).unwrap();
    sleep(Duration::from_secs(monitor.interval_secs)).await;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Command Execution — Why It's Safe&lt;br&gt;
I use std::process::Command with explicit argument separation, which calls execve directly without a shell:&lt;br&gt;
rustasync fn execute_command(monitor: &amp;amp;Monitor) -&amp;gt; MonitorResult {&lt;br&gt;
    let output = Command::new(&amp;amp;monitor.command)&lt;br&gt;
        .args(&amp;amp;monitor.args)&lt;br&gt;
        .output()&lt;br&gt;
        .await?;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
This means even if a user types ; rm -rf / in the args field, it's treated as a literal string argument — not shell syntax. No injection possible at the OS level.&lt;/p&gt;

&lt;p&gt;Gemini AI Error Analysis&lt;br&gt;
When a script exits with a non-zero code and analyze_errors is enabled, I send the stdout/stderr to Gemini:&lt;br&gt;
rustasync fn analyze_error(output: &amp;amp;str, api_key: &amp;amp;str) -&amp;gt; Result {&lt;br&gt;
    let prompt = format!(&lt;br&gt;
        "Analyze this error output and provide a concise diagnosis:\n\n{}",&lt;br&gt;
        output&lt;br&gt;
    );&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let response = reqwest::Client::new()
    .post("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent")
    .header("X-goog-api-key", api_key)
    .json(&amp;amp;serde_json::json!({
        "contents": [{"parts": [{"text": prompt}]}]
    }))
    .send()
    .await?;

// parse response...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Secure API Key Storage with macOS Keychain&lt;br&gt;
Never store API keys in plain JSON files. I use the keyring crate to store secrets in macOS Keychain:&lt;br&gt;
rustuse keyring::Entry;&lt;/p&gt;

&lt;p&gt;fn save_api_key(key: &amp;amp;str) -&amp;gt; Result&amp;lt;()&amp;gt; {&lt;br&gt;
    let entry = Entry::new("hiyoko-bar", "gemini-api-key")?;&lt;br&gt;
    entry.set_password(key)?;&lt;br&gt;
    Ok(())&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;fn load_api_key() -&amp;gt; Result {&lt;br&gt;
    let entry = Entry::new("hiyoko-bar", "gemini-api-key")?;&lt;br&gt;
    entry.get_password()&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;License Verification with Machine Binding&lt;br&gt;
To prevent license sharing, I bind each license to a machine fingerprint:&lt;br&gt;
rustuse sha2::{Sha256, Digest};&lt;/p&gt;

&lt;p&gt;fn get_machine_id() -&amp;gt; String {&lt;br&gt;
    let uuid = get_io_platform_uuid(); // IOPlatformUUID via IOKit&lt;br&gt;
    let hostname = hostname::get().unwrap_or_default();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let mut hasher = Sha256::new();
hasher.update(format!("{}{}", uuid, hostname.to_string_lossy()));

format!("{:.16x}", hasher.finalize())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
The license is verified against Gumroad's API on first activation, then cached locally with a 7-day offline grace period.&lt;/p&gt;

&lt;p&gt;Window Hide on Focus Loss&lt;br&gt;
A subtle but important UX detail — the HUD should disappear when you click elsewhere:&lt;br&gt;
rustwindow.on_window_event(|event| {&lt;br&gt;
    if let WindowEvent::Focused(false) = event {&lt;br&gt;
        window.hide().unwrap();&lt;br&gt;
    }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Result&lt;br&gt;
The app went from idea to Gumroad listing in a single day, reusing architecture from my previous Tauri apps. Rust + Tauri 2.0 is an incredibly productive stack for macOS utilities.&lt;br&gt;
&lt;strong&gt;HiyokoBar&lt;/strong&gt;: &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🇯🇵 日本語版: &lt;a href="https://hiyokoko.gumroad.com/l/hiyokobar_jp" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokobar_jp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🐤 Launched on Product Hunt today — would love your support!&lt;br&gt;
&lt;a href="https://www.producthunt.com/products/hiyokobar" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/hiyokobar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;X: &lt;a href="https://x.com/hiyoyok" rel="noopener noreferrer"&gt;@hiyoyoko&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>tauri</category>
      <category>gemini</category>
    </item>
    <item>
      <title>1,000-Page PDF. No Freeze. Here's the Rendering Architecture That Made It Possible. [Devlog #4]</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Mon, 20 Apr 2026 02:33:12 +0000</pubDate>
      <link>https://dev.to/hiyoyok/1000-page-pdf-no-freeze-heres-the-rendering-architecture-that-made-it-possible-devlog-4-40e7</link>
      <guid>https://dev.to/hiyoyok/1000-page-pdf-no-freeze-heres-the-rendering-architecture-that-made-it-possible-devlog-4-40e7</guid>
      <description>&lt;p&gt;My first implementation was simple.&lt;/p&gt;

&lt;p&gt;Open PDF → render all pages → dump into the DOM.&lt;/p&gt;

&lt;p&gt;It worked fine up to about 100 pages. At 300 it started dragging. At 1,000 the app froze for 8 seconds on open.&lt;/p&gt;

&lt;p&gt;The fix wasn't clever. It was just the right architecture from the start — and here's exactly what that looks like.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core problem: rendering what you can't see
&lt;/h2&gt;

&lt;p&gt;Loading a 1,000-page PDF shouldn't mean processing 1,000 pages of content. At any given moment, a user sees maybe 2-3 pages.&lt;/p&gt;

&lt;p&gt;The solution is virtual scrolling — only render what's visible, destroy what isn't.&lt;/p&gt;

&lt;p&gt;The tricky part for PDFs specifically: you need each page's dimensions &lt;em&gt;before&lt;/em&gt; rendering it, so the scroll container knows its total height. PDF pages aren't always the same size.&lt;/p&gt;

&lt;p&gt;I solve this upfront with lopdf, reading just the MediaBox from each page:&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="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;get_page_sizes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&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;Document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&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;doc&lt;/span&gt;&lt;span class="nf"&gt;.page_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;page_id&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="nf"&gt;.get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&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;media_box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="nf"&gt;.as_dict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b"MediaBox"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="nf"&gt;.as_array&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.as_float&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;595.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.as_float&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;842.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mf"&gt;595.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;842.0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;media_box&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="nf"&gt;.collect&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 runs instantly — no rendering, just metadata. Now the scroll container knows exactly how tall it needs to be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ghost Batch: eliminating process spawn overhead
&lt;/h2&gt;

&lt;p&gt;Virtual scrolling alone still caused jank. Every scroll event fired a new render request, and each one had process spawn overhead.&lt;/p&gt;

&lt;p&gt;Ghost Batch fixes this by queuing render requests and processing them together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without Ghost Batch:
scroll → spawn process → render page A → return
scroll → spawn process → render page B → return

With Ghost Batch:
scroll → queue page A
scroll → queue page B
queue threshold hit → spawn once → render A + B together → return
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This alone cut process creation overhead by ~90% in practice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Intelligent Prefetch: rendering ahead of the user
&lt;/h2&gt;

&lt;p&gt;Detect scroll direction, pre-render the next 2 pages in the background:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleScroll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UIEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientHeight&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&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;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prevScrollTop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;up&lt;/span&gt;&lt;span class="dl"&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;visiblePages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getVisiblePages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientHeight&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;prefetchPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;visiblePages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visiblePages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&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="nx"&gt;visiblePages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visiblePages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="nx"&gt;prefetchPages&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;prefetchPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="nx"&gt;prevScrollTop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scrollTop&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;By the time the user scrolls to the next page, it's already rendered.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000-page open time&lt;/td&gt;
&lt;td&gt;~8s freeze&lt;/td&gt;
&lt;td&gt;Near-instant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory usage&lt;/td&gt;
&lt;td&gt;All pages&lt;/td&gt;
&lt;td&gt;~3x visible pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scroll jank&lt;/td&gt;
&lt;td&gt;Noticeable&lt;/td&gt;
&lt;td&gt;Gone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Current state (dev build)
&lt;/h2&gt;

&lt;p&gt;1,000+ pages, no freeze. The architecture holds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next devlog
&lt;/h2&gt;

&lt;p&gt;Magic Pipeline — chaining OCR → compress → save into a single click. The workflow automation engine behind it.&lt;/p&gt;




&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a href="https://x.com/hiyoyok" rel="noopener noreferrer"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>pdf</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Most "privacy-focused" PDF Tools Make One Quiet Compromise. Mine Doesn't. [Devlog #3]</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Sun, 19 Apr 2026 03:33:48 +0000</pubDate>
      <link>https://dev.to/hiyoyok/most-privacy-focused-pdf-tools-make-one-quiet-compromise-mine-doesnt-devlog-3-452l</link>
      <guid>https://dev.to/hiyoyok/most-privacy-focused-pdf-tools-make-one-quiet-compromise-mine-doesnt-devlog-3-452l</guid>
      <description>&lt;p&gt;Most "privacy-focused" PDF tools make one quiet compromise.&lt;br&gt;
They still phone home.&lt;/p&gt;

&lt;p&gt;Hiyoko doesn't. Not once.&lt;/p&gt;

&lt;p&gt;Here's the architecture decision behind that —&lt;br&gt;
and what breaks if you get the encryption wrong.&lt;/p&gt;


&lt;h2&gt;
  
  
  The rule I set on day one
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The app only touches the network when the user explicitly asks it to.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;PDFs carry contracts, medical records, tax documents. A lot of tools still run background telemetry or license checks while you work. I didn't want any of that.&lt;/p&gt;

&lt;p&gt;Everything in Hiyoko PDF Vault runs locally. No exceptions.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why AES-256-GCM — and what breaks with CBC
&lt;/h2&gt;

&lt;p&gt;This is where most implementations quietly fail.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Integrity check&lt;/th&gt;
&lt;th&gt;What happens if someone tampers with the file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AES-256-CBC&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;You decrypt corrupted garbage. Silently.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AES-256-GCM&lt;/td&gt;
&lt;td&gt;Built-in (AEAD)&lt;/td&gt;
&lt;td&gt;Decryption fails immediately. You know.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CBC encrypts fine — but it has no authentication. That means a tampered ciphertext can decrypt into something without raising any alarm. You'd need to bolt on HMAC separately, and that's exactly where implementation mistakes happen.&lt;/p&gt;

&lt;p&gt;GCM gives you encryption + integrity in one pass. Wrong password or modified file → immediate failure, clear error. No ambiguity.&lt;/p&gt;


&lt;h2&gt;
  
  
  Rust implementation
&lt;/h2&gt;

&lt;p&gt;Using the &lt;code&gt;aes-gcm&lt;/code&gt; crate:&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;use&lt;/span&gt; &lt;span class="nn"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="nn"&gt;aead&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Aead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KeyInit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OsRng&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Aes256Gcm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;aead&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;rand_core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RngCore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;encrypt_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Aes256Gcm&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="nf"&gt;.into&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;nonce_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;OsRng&lt;/span&gt;&lt;span class="nf"&gt;.fill_bytes&lt;/span&gt;&lt;span class="p"&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;nonce_bytes&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;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Nonce&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_slice&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;nonce_bytes&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;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;
        &lt;span class="nf"&gt;.encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// prepend nonce so we can recover it on decrypt&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nonce_bytes&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="nf"&gt;.extend_from_slice&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;ciphertext&lt;/span&gt;&lt;span class="p"&gt;);&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;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;decrypt_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"data too short"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonce_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.split_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Aes256Gcm&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="nf"&gt;.into&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;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Nonce&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonce_bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;cipher&lt;/span&gt;
        &lt;span class="nf"&gt;.decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"decryption failed — wrong password or corrupted file"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&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;Nonce is randomly generated on every encrypt call. Same file encrypted twice → completely different output. Makes pattern analysis useless.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key derivation: why Argon2id over bcrypt
&lt;/h2&gt;

&lt;p&gt;User passwords are variable length, so we need a fixed 32-byte key. The choice of KDF matters more than most people realize.&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="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;derive_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&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="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nn"&gt;Argon2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.hash_password_into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="nf"&gt;.as_bytes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"key derivation failed"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Argon2id is memory-hard. Brute-forcing it requires not just compute but RAM — which makes GPU-based attacks expensive. bcrypt and PBKDF2 don't have that property. For new implementations, there's no reason not to use Argon2id.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "zero leak" actually means in practice
&lt;/h2&gt;

&lt;p&gt;Beyond no network calls, I built a few extra guarantees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Temp files go under &lt;code&gt;NSTemporaryDirectory()&lt;/code&gt;, deleted immediately after processing — not dumped in &lt;code&gt;/tmp&lt;/code&gt; and forgotten&lt;/li&gt;
&lt;li&gt;Clipboard auto-clears 30 seconds after sensitive text is copied (in progress)&lt;/li&gt;
&lt;li&gt;Log files record file paths and operation types only — never file contents&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Current state (dev build)
&lt;/h2&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%2Fp258mompde93o4aep7bd.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%2Fp258mompde93o4aep7bd.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wrong password → GCM auth tag verification fails instantly → clear error message. The user always knows exactly what went wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;1000-page PDFs rendered without freezing — virtual scroll + Ghost Batch architecture.&lt;/p&gt;




&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a href="https://x.com/hiyoyok" rel="noopener noreferrer"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>pdf</category>
      <category>programming</category>
    </item>
    <item>
      <title>Calling Apple Vision API from Tauri for Offline OCR [PDF Devlog #2]</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Fri, 17 Apr 2026 09:28:59 +0000</pubDate>
      <link>https://dev.to/hiyoyok/calling-apple-vision-api-from-tauri-for-offline-ocr-pdf-devlog-2-3mkb</link>
      <guid>https://dev.to/hiyoyok/calling-apple-vision-api-from-tauri-for-offline-ocr-pdf-devlog-2-3mkb</guid>
      <description>&lt;p&gt;Devlog #2. Last time I covered the hybrid PDF engine setup (Rust + PDFKit + Swift). This time: getting Apple Vision API working from Tauri for fully offline OCR.&lt;/p&gt;




&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;p&gt;Extract text from scanned/image-based PDFs — completely offline, no cloud involved.&lt;/p&gt;

&lt;p&gt;Apple's Vision Framework ships with every Mac, handles Japanese and English well, and costs nothing. The challenge: Tauri is Rust-based, and you can't call Swift APIs directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bridge architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React (UI)
  ↓ invoke()
Tauri Command (Rust)
  ↓ std::process::Command
Swift CLI binary
  ↓
Apple Vision Framework (OCR)
  ↓ stdout (JSON)
Rust → back to React
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compile Swift as a standalone CLI binary, call it from Rust as a child process. Simple, effective.&lt;/p&gt;




&lt;h2&gt;
  
  
  Swift CLI (excerpt)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Vision&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Foundation&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;recognizeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;imagePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;contentsOfFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;imagePath&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cgImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forProposedRect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;hints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;image load failed&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;}"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNRecognizeTextRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;observations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;VNRecognizedTextObservation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&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="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;observations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compactMap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;topCandidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recognitionLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accurate&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usesLanguageCorrection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recognitionLanguages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"ja-JP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"en-US"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNImageRequestHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;JSONSerialization&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;withJSONObject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;separator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"{}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CommandLine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;recognizeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;imagePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&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;h2&gt;
  
  
  Rust caller (excerpt)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;process&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;ocr_pdf_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/path/to/vision-ocr-cli"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.output&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&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;stdout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;String&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_utf8_lossy&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;output&lt;/span&gt;&lt;span class="py"&gt;.stdout&lt;/span&gt;&lt;span class="p"&gt;);&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;stdout&lt;/span&gt;&lt;span class="nf"&gt;.to_string&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;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Sandbox permissions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tauri apps run sandboxed by default. External process execution needs an explicit allowlist in &lt;code&gt;tauri.conf.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"allowlist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shell"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"execute"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Japanese OCR accuracy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without setting &lt;code&gt;recognitionLanguages&lt;/code&gt;, Vision defaults to English-first. Adding &lt;code&gt;["ja-JP", "en-US"]&lt;/code&gt; made a significant difference for Japanese documents.&lt;/p&gt;




&lt;h2&gt;
  
  
  Current state (dev build)
&lt;/h2&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%2F437i3yh6mzspm5y1ene2.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%2F437i3yh6mzspm5y1ene2.png" alt=" "&gt;&lt;/a&gt;&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%2F2b2rxkq61g7icmdmxul3.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%2F2b2rxkq61g7icmdmxul3.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Load a scanned PDF, get selectable/copyable text out. Everything runs locally — no data leaves the machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next devlog
&lt;/h2&gt;

&lt;p&gt;AES-256 encryption and Zero Leak Architecture — how I designed a PDF tool that never touches the internet.&lt;/p&gt;




&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a href="https://x.com/hiyoyok" rel="noopener noreferrer"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>ocr</category>
      <category>pdf</category>
    </item>
    <item>
      <title>I'm Building My Own PDF Tool to Escape Adobe Tax — Here's How It's Going [Devlog #1]</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Thu, 16 Apr 2026 12:11:49 +0000</pubDate>
      <link>https://dev.to/hiyoyok/im-building-my-own-pdf-tool-to-escape-adobe-tax-heres-how-its-going-devlog-1-4og3</link>
      <guid>https://dev.to/hiyoyok/im-building-my-own-pdf-tool-to-escape-adobe-tax-heres-how-its-going-devlog-1-4og3</guid>
      <description>&lt;p&gt;A few days ago I published a post about building a PDF tool in Rust because I got tired of paying for Adobe.&lt;/p&gt;

&lt;p&gt;It got more attention than I expected, so I figured: why not document the actual build process as I go?&lt;/p&gt;

&lt;p&gt;This is devlog #1.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Rust + Tauri + PDFKit? (The hybrid approach)
&lt;/h2&gt;

&lt;p&gt;The first real problem when building a PDF app: which PDF engine do you use?&lt;/p&gt;

&lt;p&gt;I went through a few options before landing on a "hybrid" architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pure lopdf (Rust)
&lt;/h3&gt;

&lt;p&gt;My first instinct was to handle everything in Rust with lopdf. Clean, fast, no Apple dependencies.&lt;/p&gt;

&lt;p&gt;Reality: macOS PDF rendering is surprisingly deep. lopdf is great for manipulation, but high-quality rendering alone is rough.&lt;/p&gt;

&lt;h3&gt;
  
  
  macOS PDFKit (Swift)
&lt;/h3&gt;

&lt;p&gt;Apple's native PDF engine — excellent rendering quality, it's what Preview uses.&lt;/p&gt;

&lt;p&gt;The catch: bridging it cleanly from Tauri's Rust backend takes work.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution: clear separation of concerns
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PDFKit  → page rendering, redaction
lopdf   → Bates numbering, metadata manipulation  
Swift   → Apple Vision API (OCR), Core Image filters
Rust    → encryption, batch logic, all business logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer has one clear job. Much cleaner than trying to force one engine to do everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm building right now
&lt;/h2&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%2Fgfonzh58znf4a9nqbhi2.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%2Fgfonzh58znf4a9nqbhi2.png" alt=" " width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Currently working on the &lt;strong&gt;Sanctuary Automator&lt;/strong&gt; — a workflow engine that chains operations like OCR → compress → save into a single click.&lt;/p&gt;

&lt;p&gt;Still rough in the dev build, but the pipeline architecture is coming together.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next devlog
&lt;/h2&gt;

&lt;p&gt;Calling Apple Vision API from Tauri for offline OCR — bridging Swift into Rust/Tauri isn't well documented. I'll walk through how I got it working.&lt;/p&gt;




&lt;p&gt;Hiyoko PDF Vault → &lt;a href="https://hiyokoko.gumroad.com/l/HiyokoPDFVault" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/HiyokoPDFVault&lt;/a&gt;&lt;br&gt;
X → &lt;a href="https://x.com/hiyoyok" rel="noopener noreferrer"&gt;@hiyoyok&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>pdf</category>
      <category>programming</category>
    </item>
    <item>
      <title>I launched on Product Hunt with 0 followers — honest results</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Thu, 16 Apr 2026 05:37:01 +0000</pubDate>
      <link>https://dev.to/hiyoyok/i-launched-on-product-hunt-with-0-followers-honest-results-3a28</link>
      <guid>https://dev.to/hiyoyok/i-launched-on-product-hunt-with-0-followers-honest-results-3a28</guid>
      <description>&lt;p&gt;&lt;strong&gt;I launched on Product Hunt with 0 followers — honest results&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yesterday, I launched my macOS app "Hiyoko AutoSync" on Product Hunt.&lt;br&gt;
The result: 4 upvotes. Small number on paper. But honestly? More reaction than I expected.&lt;/p&gt;

&lt;p&gt;What I was actually afraid of&lt;br&gt;
It wasn't nervousness exactly — it was the fear that nobody would even see it.&lt;br&gt;
Zero followers on X. Maybe 1–2 on other platforms. On dev.to, I had no idea if anyone was reading at all. I launched with basically no social foundation whatsoever.&lt;/p&gt;

&lt;p&gt;What actually happened&lt;br&gt;
People saw it. For real.&lt;br&gt;
Even with zero followers, getting listed on Product Hunt's feed means people can find you. The people who upvoted me had no connection to my social accounts — they were strangers who just came across it.&lt;br&gt;
The feeling of "someone knows this exists now" hit harder than the number itself.&lt;/p&gt;

&lt;p&gt;What I learned as an indie dev&lt;br&gt;
・Launching with zero followers is still worth doing&lt;br&gt;
・Even when you're scared, the move is to take action and get yourself out there&lt;br&gt;
・The act of shipping itself becomes the foundation for what comes next&lt;/p&gt;

&lt;p&gt;I'll keep launching&lt;br&gt;
I have more apps I'm proud of under the Hiyoko brand. With this experience under my belt, I think I can do better next time.&lt;br&gt;
Product Hunt isn't just for people with an audience — and I'm going to keep proving that.&lt;/p&gt;

&lt;p&gt;If you're curious, the Product Hunt launch is still live for the next couple of hours — would love your support!&lt;/p&gt;

&lt;p&gt;🐤Hiyoko AutoSync on Product Hunt&lt;br&gt;
&lt;a href="https://www.producthunt.com/products/hiyoko-autosync?utm_source=other&amp;amp;utm_medium=social" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/hiyoko-autosync?utm_source=other&amp;amp;utm_medium=social&lt;/a&gt;&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>startup</category>
      <category>showdev</category>
      <category>indies</category>
    </item>
    <item>
      <title>I Built a Mac App That Makes Android File Sync Feel Like Magic (No More MTP Hell)</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 15 Apr 2026 13:30:06 +0000</pubDate>
      <link>https://dev.to/hiyoyok/i-built-a-mac-app-that-makes-android-file-sync-feel-like-magic-no-more-mtp-hell-e00</link>
      <guid>https://dev.to/hiyoyok/i-built-a-mac-app-that-makes-android-file-sync-feel-like-magic-no-more-mtp-hell-e00</guid>
      <description>&lt;h2&gt;
  
  
  description: Hiyoko AutoSync uses ADB + Rust/Tauri to give you zero-touch, blazing-fast file sync between your Android and Mac. Here's why I built it and how it works under the hood.
&lt;/h2&gt;

&lt;p&gt;If you've ever plugged your Android into a Mac and waited... and waited... for MTP to do literally anything — this post is for you.&lt;/p&gt;

&lt;p&gt;I've spent way too many hours staring at a progress bar reading "Copying 1 of 847 files" while my Mac and phone had a slow, painful conversation through the world's worst protocol. So I built something to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with MTP
&lt;/h2&gt;

&lt;p&gt;MTP (Media Transfer Protocol) is the default way Android and Mac communicate over USB. It works. Technically. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Speeds cap out around &lt;strong&gt;3–5 MB/s&lt;/strong&gt; in practice, even on USB 3&lt;/li&gt;
&lt;li&gt;Connections are flaky and drop mid-transfer&lt;/li&gt;
&lt;li&gt;You have to manually tap "File Transfer Mode" on your phone every. single. time.&lt;/li&gt;
&lt;li&gt;There's zero automation — you have to babysit every sync&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For me, the daily ritual looked like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Plug in phone → tap "File Transfer" on Android → open sync app on Mac → manually start sync → wait&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Multiply that by every day, for years. I got fed up and built my way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: ADB Instead of MTP
&lt;/h2&gt;

&lt;p&gt;ADB (Android Debug Bridge) ships with the Android SDK and is designed for development tooling. But it's also a &lt;strong&gt;dramatically superior file transfer protocol&lt;/strong&gt; that almost no one uses for everyday sync.&lt;/p&gt;

&lt;p&gt;With ADB you get rock-solid connections, full scriptability, and speeds up to &lt;strong&gt;45 MB/s on USB 3.x&lt;/strong&gt; — roughly 10x what MTP delivers in practice.&lt;/p&gt;

&lt;p&gt;The one-time cost: enabling Developer Mode and USB Debugging on your Android. For anyone technical enough to care about fast, reliable sync, that's a 30-second setup you do once and forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built: Hiyoko AutoSync
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hiyoko AutoSync&lt;/strong&gt; is a macOS menu bar app that wraps ADB in a zero-touch experience. Here's the full flow after the one-time setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Plug in your Android via USB&lt;/li&gt;
&lt;li&gt;HiyokoAutoSync detects the device and auto-switches USB mode to ADB&lt;/li&gt;
&lt;li&gt;Sync starts instantly — &lt;strong&gt;zero taps on your phone&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Next time, it connects over Wi-Fi. You may never need the cable again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The entire manual ritual is gone.&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%2Fi337bff90bt0945oy5yz.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%2Fi337bff90bt0945oy5yz.png" alt=" " width="648" height="804"&gt;&lt;/a&gt;&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%2Fvfwyzklcc75o5m2g7ltf.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%2Fvfwyzklcc75o5m2g7ltf.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the Hood
&lt;/h2&gt;

&lt;p&gt;This is the part I actually want to talk about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tauri + Rust (not Electron)
&lt;/h3&gt;

&lt;p&gt;The app is built with &lt;strong&gt;Tauri&lt;/strong&gt; and &lt;strong&gt;Rust&lt;/strong&gt; for the backend. I made this call specifically to avoid Electron bloat — a Mac menu bar app that eats 300MB of RAM to wrap a web UI is not a menu bar app, it's a punishment.&lt;/p&gt;

&lt;p&gt;Tauri gives a native feel with a tiny binary footprint. Rust gives me the performance headroom to do things like...&lt;/p&gt;

&lt;h3&gt;
  
  
  4-Parallel ADB Engine (Tokio)
&lt;/h3&gt;

&lt;p&gt;Standard ADB transfers one file at a time. Hiyoko AutoSync uses &lt;strong&gt;Tokio&lt;/strong&gt; (Rust's async runtime) to run &lt;strong&gt;4 parallel ADB streams simultaneously&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The result: moving gigabytes of 4K video or thousands of RAW photos takes seconds instead of minutes. Measured throughput hits 45 MB/s+ on USB 3.x connections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bundled ADB — Zero Setup
&lt;/h3&gt;

&lt;p&gt;ADB is bundled inside the app. Users don't need to install Android SDK, add things to &lt;code&gt;$PATH&lt;/code&gt;, or touch a terminal. It just works out of the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wi-Fi Auto-Sync
&lt;/h3&gt;

&lt;p&gt;Set up once over USB, and the app handles pairing your device to your local network. From then on, sync triggers automatically whenever your phone and Mac are on the same Wi-Fi — no cable required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety Net: HiyokoTrash
&lt;/h3&gt;

&lt;p&gt;Overwritten or deleted files during sync are moved to a &lt;strong&gt;HiyokoTrash&lt;/strong&gt; folder on both your Mac and Android instead of being permanently deleted. You can always recover anything that got swept up in a sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus Pro Tools
&lt;/h3&gt;

&lt;p&gt;A few extra utilities I added because I needed them myself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HEIC → JPG conversion&lt;/strong&gt; on transfer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage analyzer&lt;/strong&gt; for your Android device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe deletion&lt;/strong&gt; with trash-based recovery&lt;/li&gt;
&lt;/ul&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%2F4lpzb8rf9m9t1ezf495z.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%2F4lpzb8rf9m9t1ezf495z.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&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%2F7j3ak4kigfgbollyyufg.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%2F7j3ak4kigfgbollyyufg.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&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%2F9ntja0v0y45kmlf7chj2.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%2F9ntja0v0y45kmlf7chj2.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Comparison
&lt;/h2&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%2Fq9p21pfuej70tv4wam4v.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%2Fq9p21pfuej70tv4wam4v.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS 12.0 Monterey or later&lt;/strong&gt; — Universal Binary (Apple Silicon &amp;amp; Intel)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android 5.0 or later&lt;/strong&gt; — Developer Mode + USB Debugging enabled (one-time setup)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I'm Writing This Now
&lt;/h2&gt;

&lt;p&gt;I shipped to &lt;a href="https://www.producthunt.com/products/hiyoko-autosync?launch=hiyoko-autosync" rel="noopener noreferrer"&gt;Product Hunt&lt;/a&gt; and... completely forgot to write anything about it. Classic.&lt;/p&gt;

&lt;p&gt;The app has been getting solid feedback from photographers, video creators, and developers who move large files regularly between their phone and Mac. Figured it was time to actually explain the technical decisions behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;One-time purchase on Gumroad. Lifetime access to all future updates. No subscriptions.&lt;/p&gt;




&lt;p&gt;If you're curious about the Tokio parallel streaming implementation or how the ADB device detection works, drop a comment — happy to go deeper on any of it.&lt;/p&gt;

&lt;p&gt;🐤 &lt;a href="https://www.producthunt.com/products/hiyoko-autosync?launch=hiyoko-autosync" rel="noopener noreferrer"&gt;Hiyoko AutoSync on Product Hunt&lt;/a&gt; · &lt;br&gt;
[Download on Gumroad]&lt;br&gt;
&lt;a href="https://hiyokoko.gumroad.com/l/hiyokoautosync_en" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokoautosync_en&lt;/a&gt;&lt;br&gt;
&lt;a href="https://hiyokoko.gumroad.com/l/hiyokoautosync_jp" rel="noopener noreferrer"&gt;https://hiyokoko.gumroad.com/l/hiyokoautosync_jp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>rust</category>
      <category>tauri</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Stop Switching Tabs: I Built a Lightweight Rust/Tauri Menu Bar App to Summon Gemini for Instant Debugging🐤</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 15 Apr 2026 12:48:55 +0000</pubDate>
      <link>https://dev.to/hiyoyok/stop-switching-tabs-i-built-a-lightweight-rusttauri-menu-bar-app-to-summon-gemini-for-instant-3p9a</link>
      <guid>https://dev.to/hiyoyok/stop-switching-tabs-i-built-a-lightweight-rusttauri-menu-bar-app-to-summon-gemini-for-instant-3p9a</guid>
      <description>&lt;p&gt;💻 The Struggle: Coding on a 10-Year-Old Mac&lt;br&gt;
I’m still using a MacBook from 10 years ago. It’s a great machine and I love the keyboard, but modern browsers are memory hogs.&lt;/p&gt;

&lt;p&gt;Whenever I hit a bug, I used to follow this tedious routine:&lt;br&gt;
1.Open a new browser tab.&lt;br&gt;
2.Wait for the heavy UI of AI tools to load.&lt;br&gt;
3.Paste my error and wait for the response while my  Mac's fans scream at max volume.&lt;/p&gt;

&lt;p&gt;It felt like every time I tried to solve a problem, I was losing my "flow state." I needed something lighter, faster, and native.&lt;/p&gt;

&lt;p&gt;🐤 Introducing: HIYOKOHELPER&lt;br&gt;
To solve this, I built HIYOKOHELPER, a minimalist menu bar assistant powered by Rust and Tauri. It brings the power of Gemini AI directly to my desktop without the overhead of a web browser.&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%2Fa163suid8b5pm1tq47g6.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%2Fa163suid8b5pm1tq47g6.png" alt=" " width="686" height="874"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🐤 Why Rust and Tauri?&lt;br&gt;
If you're using an old machine, every megabyte of RAM counts. I chose Tauri for a few reasons:&lt;/p&gt;

&lt;p&gt;1.Tiny Footprint: Unlike Electron, which bundles a whole Chromium instance, Tauri uses the OS-native WebView.&lt;br&gt;
2.Performance: Rust handles the background tasks with incredible speed and safety.&lt;br&gt;
3.Startup Speed: It pops up instantly when I need it and hides away when I don't.&lt;/p&gt;

&lt;p&gt;The result? A dev tool that runs smoothly even on hardware from a decade ago.&lt;/p&gt;

&lt;p&gt;🛠 Key Features for Developers&lt;br&gt;
I added three specific features to make my daily coding life easier:&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%2Fbztlibdaz6n7j5ql5ojv.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%2Fbztlibdaz6n7j5ql5ojv.png" alt=" " width="700" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Auto Monitor (Clipboard Error Detection) 📋&lt;br&gt;
This is my favorite feature. When enabled, the app monitors my clipboard. If I copy an error message from my terminal or IDE, HIYOKOHELPER is already prepared to help. No more manual copying and pasting back and forth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Bring Your Own API Key (Gemini Pro Support) 🔑&lt;br&gt;
The app allows you to input your own Gemini API key. This means you can use Gemini 1.5 Pro or any other model Google offers in their developer tier. It’s private, cost-effective (often free for personal use), and fast.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Native &amp;amp; Always Ready ⚙️&lt;br&gt;
With the "Auto Start" feature, the app is ready the moment I log in. It sits quietly in the menu bar, waiting for me to "summon" it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;🌟 Conclusion: Old Hardware, Modern AI&lt;br&gt;
Working on an old machine doesn't mean you have to miss out on the latest AI breakthroughs. By choosing the right tech stack—Rust and Tauri—I was able to build a tool that makes my 10-year-old Mac feel like a modern powerhouse.&lt;/p&gt;

&lt;p&gt;If you find yourself constantly distracted by browser tabs while debugging, I highly recommend building (or finding) a native desktop assistant. It’s a game-changer for your focus!&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I launched my Mac app on Product Hunt today — zero-touch Android sync built with Rust🐤</title>
      <dc:creator>hiyoyo</dc:creator>
      <pubDate>Wed, 15 Apr 2026 07:41:49 +0000</pubDate>
      <link>https://dev.to/hiyoyok/i-launched-my-mac-app-on-product-hunt-today-zero-touch-android-sync-built-with-rust-3ngm</link>
      <guid>https://dev.to/hiyoyok/i-launched-my-mac-app-on-product-hunt-today-zero-touch-android-sync-built-with-rust-3ngm</guid>
      <description>&lt;p&gt;Hey devs! I just launched HiyokoAutoSync on Product Hunt today.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.producthunt.com/products/hiyoko-autosync" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/hiyoko-autosync&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's a Mac app that makes Android sync completely invisible — plug in via USB once, it auto-switches to MTP mode via ADB, starts syncing, and pairs over Wi-Fi so you never need the cable again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built with:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tauri + Rust (no Electron bloat)&lt;/li&gt;
&lt;li&gt;Tokio async runtime, 4-parallel engine (45MB/s+)&lt;/li&gt;
&lt;li&gt;Bundled ADB binary — works out of the box&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Solo indie dev here. Would love any feedback if this sounds useful to you! 🐤&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>android</category>
      <category>indies</category>
    </item>
  </channel>
</rss>
