AI 资讯
Bundling a CLI Binary as a Tauri v2 Sidecar: Lessons from Building a Desktop App
When you build a desktop app with Tauri v2 , sooner or later you'll hit a question: how do I bundle and manage an external CLI binary inside my app? Maybe it's ffmpeg for video processing. Maybe it's a database engine. Maybe — as in my case — it's frpc , the reverse-proxy client from the popular frp project. This post walks through the full lifecycle: bundling, spawning, lifecycle management, and even self-updating the binary at runtime — all from Rust. 1. Declaring the Sidecar In tauri.conf.json , declare the binary under bundle.externalBin : { "bundle" : { "externalBin" : [ "binaries/frpc" ] } } Tauri identifies the target platform by a filename suffix convention . You need to place the correctly-named binary in your project: Platform Filename macOS (Apple Silicon) frpc-aarch64-apple-darwin macOS (Intel) frpc-x86_64-apple-darwin Windows (x64) frpc-x86_64-pc-windows-msvc.exe Tauri automatically strips the suffix at runtime and loads the right binary for the current platform. 2. Spawning the Process Use tauri_plugin_shell to spawn the sidecar: use tauri_plugin_shell ::{ ShellExt , process :: CommandEvent }; #[tauri::command] async fn start_frpc ( app : tauri :: AppHandle ) -> Result < (), String > { let sidecar = app .shell () .sidecar ( "frpc" ) .map_err (| e | e .to_string ()) ? ; let ( mut rx , child ) = sidecar .args ([ "-c" , "frpc.toml" ]) .spawn () .map_err (| e | e .to_string ()) ? ; // Store the child handle so we can kill it later app .state :: < std :: sync :: Mutex < Option < tauri_plugin_shell :: process :: CommandChild >>> () .lock () .unwrap () .replace ( child ); // Listen to stdout/stderr in a background task tauri :: async_runtime :: spawn ( async move { while let Some ( event ) = rx .recv () .await { match event { CommandEvent :: Stdout ( line ) => { // Parse log line, update UI state... } CommandEvent :: Stderr ( line ) => { /* ... */ } CommandEvent :: Terminated ( _ ) => { // Process exited — update state machine } _ => {} } } }); Ok (()) } The
AI 资讯
lopdf vs pdfium in Rust — What I Learned Building a PDF App
All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion. I built Hiyoko PDF Vault — a macOS PDF tool — in Rust. Choosing the right PDF library was the first real decision. lopdf or pdfium. Here's what I found. lopdf: pure Rust, no dependencies lopdf is pure Rust. No C bindings, no system libraries, no bundling headaches. What it does well: Merge, split, rotate pages Read and write PDF structure Metadata manipulation Bates numbering Works well for structural PDF operations What it struggles with: Rendering PDFs to images (not its job) Complex font handling Malformed PDFs — lopdf is strict; real-world PDFs often aren't For a tool that manipulates PDF structure without rendering — merge, split, encrypt, add watermarks, strip metadata — lopdf is the right choice. Pure Rust means easy cross-compilation and universal binaries with no extra work. pdfium: full rendering, C dependency pdfium is Google's PDF engine (from Chromium). The pdfium-render crate wraps it for Rust. What it does well: Accurate PDF rendering to images Handles malformed PDFs that lopdf rejects Text extraction from complex layouts Full PDF spec compliance What it requires: Bundling the pdfium binary with your app (~20MB) Architecture-specific binaries (x86_64 and aarch64 for universal binary) More complex build setup For a tool that needs to display PDFs or extract text from complex documents, pdfium is the right choice. You pay for it in bundle size and build complexity. What I actually use lopdf for structural operations: merge, split, encrypt, watermark, metadata, Bates numbering. Apple Vision Framework (via Tauri shell commands) for OCR — it's already on the user's Mac and handles Japanese text better than anything I could bundle. I avoided pdfium because the bundle size increase wasn't worth it for my use case. If I needed accurate rendering, that calculation would change. The honest recommendation Start with lopdf. It covers most PD
AI 资讯
Tauri v2 Cheatsheet — The Commands I Use on Every Project
All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion. After 7 Tauri apps, I type the same commands constantly. Here's the reference I wish existed when I started. Project setup # New project npm create tauri-app@latest # Add to existing project npm install --save-dev @tauri-apps/cli npx tauri init Development # Dev mode (hot reload) npm run tauri dev # Dev with specific log level RUST_LOG = debug npm run tauri dev # Dev with backend logs visible npm run tauri dev 2>&1 | grep -v "^$" Building # Standard build npm run tauri build # Universal binary (Intel + Apple Silicon) npm run tauri build -- --target universal-apple-darwin # Debug build (faster, no optimization) npm run tauri build -- --debug Plugins npm run tauri add global-shortcut npm run tauri add fs npm run tauri add shell npm run tauri add notification This updates both Cargo.toml and the plugin registration. Faster than doing it manually. Permissions (tauri.conf.json) { "app" : { "security" : { "capabilities" : [ { "identifier" : "main-capability" , "description" : "Main window capabilities" , "windows" : [ "main" ], "permissions" : [ "fs:read-all" , "fs:write-all" , "shell:execute" , "global-shortcut:allow-register" ] } ] } } } Tauri v2 requires explicit permission declarations. If a command silently does nothing, check permissions first. Common Rust patterns // Get app data directory let data_dir = app .path () .app_data_dir () .unwrap (); // Emit event to frontend app_handle .emit ( "event-name" , payload ) .ok (); // Get window let window = app .get_webview_window ( "main" ) .unwrap (); // App state app .manage ( MyState :: new ()); let state = app .state :: < MyState > (); Notarization (macOS) # Submit for notarization xcrun notarytool submit app.dmg \ --apple-id YOUR_APPLE_ID \ --team-id YOUR_TEAM_ID \ --password YOUR_APP_PASSWORD \ --wait # Staple after notarization xcrun stapler staple app.dmg Debugging # Check what's in the bundle
AI 资讯
I Built a Feature That Automatically Switches Android from USB to Wi-Fi — Here's How It Works
All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion. You plug in your Android device. A few seconds later, you unplug the cable. The connection stays alive — wirelessly, automatically, without touching a single setting. That's Seamless Link. The Problem ADB over USB is reliable. ADB over Wi-Fi is convenient. But switching between them manually is friction: Plug in USB Run adb tcpip 5555 Find the device IP Run adb connect <IP>:5555 Unplug cable Every. Single. Time. If you're working with multiple Android devices, or doing this across multiple sessions per day, it adds up fast. What Seamless Link Does The moment you plug in a USB cable, Seamless Link runs that entire flow automatically in the background: Detects the USB connection Runs adb tcpip 5555 Grabs the device IP Establishes a Wi-Fi ADB connection By the time you've sat back down, the device is already connected wirelessly. Pull the cable out — everything keeps working. No manual steps. No IP hunting. No re-running commands every session. Working with Multiple Devices This gets more useful the more devices you have. Plug in Device A → Seamless Link connects it wirelessly. Plug in Device B → same thing, simultaneously. Each device goes through the full handover flow independently, in parallel. The more devices on your desk, the more time this saves. Android 16 Compatibility Android 16 changed how wireless debugging ports are assigned — random ports instead of the fixed 5555. Seamless Link handles this automatically. You don't need to know which port the device is using. If you're on an older Android version, it works the same way it always has. Why This Matters for Daily Workflows If you're an Android developer on Mac, you probably already have a USB cable on your desk. Seamless Link just makes that cable optional after the first few seconds. It's one of those features that's hard to go back from once you've used it. The cable becomes a "char
AI 资讯
QuickLook Integration in a Tauri App — Native macOS File Preview
All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion. HiyokoKit's MTP file manager includes QuickLook preview. Press Space, see the file. Native macOS behavior in a Tauri app. Here's how it works — and why it's worth doing. What QuickLook Is QuickLook is macOS's built-in file preview system. Press Space on any file in Finder — that's QuickLook. It handles images, PDFs, videos, and documents without opening separate apps. For a file manager, QuickLook preview is table stakes on macOS. Users expect it. If it's missing, the app feels unfinished. Triggering QuickLook from Rust The qlmanage command-line tool can trigger QuickLook from any process: use std :: process :: Command ; #[tauri::command] async fn preview_file ( file_path : String ) -> Result < (), AppError > { Command :: new ( "qlmanage" ) .args ([ "-p" , & file_path ]) .spawn () .map_err (| e | AppError :: Preview ( e .to_string ())) ? ; Ok (()) } qlmanage -p opens a native QuickLook preview window for the specified path. That's it on the Rust side for local files. For MTP Files: Download First, Preview, Cleanup Files on an Android device don't have a local path — they live on the device over MTP. The flow is: download to a temp file → preview → clean up. #[tauri::command] async fn preview_mtp_file ( device_path : String , filename : String , ) -> Result < (), AppError > { // Download to temp let temp_path = std :: env :: temp_dir () .join ( & filename ); download_from_device ( & device_path , & temp_path ) .await ? ; // Open QuickLook Command :: new ( "qlmanage" ) .args ([ "-p" , temp_path .to_str () .unwrap ()]) .spawn () ? ; // Schedule cleanup after delay let temp_clone = temp_path .clone (); tokio :: spawn ( async move { tokio :: time :: sleep ( Duration :: from_secs ( 30 )) .await ; std :: fs :: remove_file ( temp_clone ) .ok (); }); Ok (()) } 30 seconds gives the user time to view before cleanup. For large files (RAW photos, videos), y
AI 资讯
Why MTP Batch Transfers Slow Down Between Files
All tests run on an 8-year-old MacBook Air. You're transferring a batch of large files over MTP. The first one flies at 45 MB/s. Then the second file starts — and you're at 30 MB/s. The third is slower still. Nothing changed. Same cable, same device, same app. So what's happening? The Cause Is in the Protocol Itself Between every file, MTP requires a full negotiation cycle — SendObjectInfo followed by SendObject . This isn't an implementation detail you can optimize away. It's how MTP works. During that gap, a few things happen in sequence: The Android device's flash controller is still committing the previous file to storage The USB pipe is flushed and re-established for the next object The device's MTP stack is processing metadata before it's ready to receive data again The result is a speed dip at every file boundary. The longer the previous file, the longer the device needs to catch up. What I Tried Building HiyokoMTP, I went through the obvious candidates: Tokio thread pool exhaustion — sync Read/Write calls blocking async threads were a real issue. Fixing it improved overall stability, but didn't eliminate the inter-file dip. Chunk size tuning — adjusting the USB bulk transfer buffer (up to 4 MB per chunk) helped peak throughput, but not the boundary behavior. Intentional cooldown between files — adding a short pause actually helped in some cases, giving the device's flash controller time to breathe before the next transfer starts. Why It Can't Be Fully Fixed The inter-file overhead is structural. MTP was designed as a stateful, command-response protocol — not a streaming pipeline. Every file is a discrete transaction with its own negotiation. There's no mechanism to pre-stage the next file while the current one is still writing. Non-async bulk transfer pipelining (similar to io_uring or Zero Copy USB) could theoretically reduce this, but it would require deep nusb-level changes and device-side support that most Android MTP stacks don't expose. MTP vs ADB: A F
AI 资讯
Tauri Sandbox Permissions — Why Your Command Silently Does Nothing
All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion. The most common Tauri v2 frustration: you write a command, invoke it from the frontend, and nothing happens. No error. No crash. Just silence. It's almost always permissions. How Tauri v2 permissions work Tauri v2 introduced a capability system. Every plugin action — reading files, executing shell commands, sending notifications — requires an explicit permission declaration in your config. Without the permission, the plugin call fails silently on the frontend. The Rust code never runs. // src-tauri/capabilities/main.json { "identifier" : "main-capability" , "description" : "Permissions for main window" , "windows" : [ "main" ], "permissions" : [ "core:default" , "fs:read-all" , "fs:write-all" , "shell:allow-execute" , "opener:allow-open" , "global-shortcut:allow-register" , "global-shortcut:allow-unregister" ] } Note: As of Tauri v2.1, shell:allow-open is deprecated. Use tauri-plugin-opener and opener:allow-open instead. The debugging flow When a command does nothing: Open DevTools ( Cmd+Option+I in dev mode) — check the console for a rejected Promise or permission error Check your terminal output — the Rust side logs errors directly in the tauri dev terminal; look for lines like [tauri] permission denied or not allowed Enable verbose logging — set RUST_LOG=tauri=debug before running tauri dev for more detailed backend output Check your capabilities file — missing or misspelled permission identifiers are the #1 cause Permission errors in the console typically look like a rejected Promise with a message such as plugin:shell|execute not allowed . The capabilities file is always the first thing to check. Common permissions you'll need "permissions" : [ "core:default" , "fs:read-all" , // read any file "fs:write-all" , // write any file { "identifier" : "shell:allow-execute" , "allow" : [{ "name" : "my-cmd" , "cmd" : "adb" , "args" : true }] }, "op