All tests run on an 8-year-old MacBook Air (Intel). Migrating a single app is a weekend project. Migrating ten production apps simultaneously is a three-week marathon that tests every assumption you've made about your codebase.
Tauri v2 brings plugin modularity, a new permission system, and mobile support. But the migration path from v1 is non-trivial, especially at scale. Here's what I learned migrating my entire Hiyoko Suite—10 Rust + React macOS apps—from Tauri v1.5 to v2.
TL;DR
- The v1→v2 IPC layer changed completely:
tauri::commandstays, but frontendinvokecalls need path updates. - Plugin-based architecture replaces monolithic
tauri.conf.jsoncapabilities with per-plugin permission manifests. - Migrated 200+ IPC commands across 10 apps by building a centralized API wrapper pattern.
- Mobile support in v2 opens future doors, but macOS-specific APIs (
macos-private-api) need careful feature-gating.
IPC Layer: The Biggest Breaking Change
In Tauri v1, invoking a Rust command from the frontend was straightforward. In v2, the invoke path and error handling changed. Multiply that by 200+ commands across 10 apps, and you have a real migration project.
Here's the before/after pattern I used:
// === Tauri v1 ===
import { invoke } from '@tauri-apps/api/tauri';
const files = await invoke('list_files', { path: '/sdcard/DCIM' });
// === Tauri v2 ===
import { invoke } from '@tauri-apps/api/core';
const files = await invoke('list_files', { path: '/sdcard/DCIM' });
// Import path changed: api/tauri → api/core
The import path change seems small, but with 200+ invoke calls scattered across 10 React codebases, a find-and-replace wasn't enough. Some apps had aliased imports, some used dynamic invoke wrappers. I ended up building a centralized API layer:
// src/api/tauri-bridge.ts — centralized wrapper for all IPC calls
import { invoke } from '@tauri-apps/api/core';
export async function tauriInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(cmd, args);
} catch (error) {
// Centralized error handling — log to HiyokoBar if available
console.error(`[IPC] ${cmd} failed:`, error);
throw error;
}
}
// Usage across all apps:
const files = await tauriInvoke<FileEntry[]>('list_files', { path: '/sdcard/DCIM' });
const status = await tauriInvoke<SyncStatus>('get_sync_status');
This wrapper gave me a single point of control for logging, error formatting, and any future IPC changes.
Permission Manifests: Security Gets Granular
Tauri v2 introduces a capability-based permission system. In v1, you declared broad access in tauri.conf.json. In v2, each plugin declares its own permissions, and your app explicitly opts in.
//src-tauri/capabilities/default.json{"identifier":"default","description":"Default permissions for HiyokoMTP","windows":["main"],"permissions":["core:default","fs:default","fs:allow-read","fs:allow-write","shell:allow-open","dialog:default","store:default","notification:default"]}
For apps like HiyokoMTP that need deep filesystem access, defining granular permissions was initially tedious. But the payoff is real—users can now see exactly what each app accesses, and I can audit permissions per-app instead of relying on a monolithic config.
Plugin System: From Monolith to Modules
In v1, features like shell, dialog, and store were built into the Tauri core. In v2, they're standalone plugins you explicitly register:
// src-tauri/src/main.rs — Tauri v2 plugin registration
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.invoke_handler(tauri::generate_handler![
list_files,
transfer_file,
get_device_info,
])
.run(tauri::generate_context!())
.expect("error running HiyokoMTP");
}
The Cargo.toml dependencies change accordingly:
[dependencies]
tauri = { version = "2", features = ["macos-private-api"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-fs = "2"
tauri-plugin-notification = "2"
The migration forced me to audit which plugins each app actually used. Turns out, three of my apps were pulling in shell capabilities they never used—dead weight that v2's explicit model exposed instantly.
Migration Strategy for Multiple Apps
With 10 apps to migrate, I couldn't do them all at once. Here's the order that worked:
- Start with the simplest app (HiyokoShot — minimal IPC surface). Prove the migration pattern works.
- Build the centralized API wrapper in the first app, then copy it to all others.
-
Migrate shared crates first (
hiyoko-helper), then update all downstream apps. - Save the most complex app for last (HiyokoMTP — 60+ IPC commands, deep USB access).
The whole process took about three weeks of focused work. The first app took 4 days. By app number 10, I had the pattern down to half a day.
Tauri v2 is a significant upgrade for desktop (and now mobile) development. The migration was a marathon, but the resulting codebase is cleaner, more secure, and ready for whatever comes next.
Have you migrated a Tauri v1 app to v2 yet? What was the most unexpected breaking change you hit?
If this was helpful, check out HiyokoAutoSync — wireless zero-touch Android↔Mac file sync via ADB.
Built with Rust + Tauri v2. Tested on an 8-year-old MacBook Air.