Thomas Pedot
dimanche 22 mars 2026
Building a Production Tauri App: Architecture, Type-Safe IPC, and Cross-Platform Patterns

Most Tauri tutorials end at "Hello World." They show you how to call invoke("greet", { name }) from the frontend and return a string from Rust. That's useful, but it leaves out everything that makes a production app — typed IPC that doesn't drift, atomic file writes, cross-platform window management, a monorepo that scales, and a state architecture that doesn't collapse under complexity.
This is the architecture behind Gitlalchemy, an open-source native GitLab client built with Tauri v2. It runs on Linux, macOS, Windows, and Android from a single TypeScript + Rust codebase. Version 1.2.2. Roughly 31,800 lines of TypeScript/React and 1,265 lines of Rust.
Monorepo Layout
The project is a Turborepo monorepo with Bun as the package manager:
1apps/
2 desktop/ — Tauri app (React frontend + Rust backend)
3packages/
4 api-client/ — Type-safe GitLab API client (ky + TanStack Query)
5 state/ — Zustand stores (session, filters, consent, notifications)
6 shared/ — Shared TypeScript types and utilities
7 ui/ — shadcn/ui v4 componentsThe root package.json delegates everything through Turborepo:
1{
2 "name": "gitlab-client",
3 "version": "1.2.2",
4 "packageManager": "bun@1.3.6",
5 "workspaces": ["apps/*", "packages/*"],
6 "scripts": {
7 "dev": "turbo run dev",
8 "build": "turbo run build",
9 "typecheck": "turbo run typecheck",
10 "test": "turbo run test",
11 "check:all": "turbo run check:all"
12 }
13}And turbo.json encodes the build graph — packages are only rebuilt when their own inputs change:
1{
2 "tasks": {
3 "build": {
4 "dependsOn": ["^build"],
5 "inputs": ["$TURBO_DEFAULT$", ".env*"],
6 "outputs": [".next/**", "!.next/cache/**", "dist/**", "out/**"]
7 },
8 "typecheck": { "dependsOn": ["^build"] },
9 "test": { "dependsOn": ["^build"] },
10 "dev": { "cache": false, "persistent": true }
11 }
12}The ^build dependency means @repo/api-client and @repo/state are always compiled before @repo/desktop runs typecheck or tests. No stale type errors from workspace packages.
Type-Safe IPC with tauri-specta
The naive Tauri IPC pattern is invoke("my_command", { arg }) — a string literal that TypeScript can't validate. If you rename the Rust function, the frontend silently breaks at runtime. tauri-specta eliminates this by generating TypeScript bindings directly from Rust function signatures.
The Rust side uses two attributes:
1#[tauri::command]
2#[specta::specta]
3pub async fn load_preferences(app: AppHandle) -> Result<AppPreferences, String> {
4 log::debug!("Loading preferences from disk");
5 let prefs_path = get_preferences_path(&app)?;
6
7 if !prefs_path.exists() {
8 log::info!("Preferences file not found, using defaults");
9 return Ok(AppPreferences::default());
10 }
11
12 let contents = std::fs::read_to_string(&prefs_path).map_err(|e| {
13 log::error!("Failed to read preferences file: {e}");
14 format!("Failed to read preferences file: {e}")
15 })?;
16
17 let preferences: AppPreferences = serde_json::from_str(&contents).map_err(|e| {
18 log::error!("Failed to parse preferences JSON: {e}");
19 format!("Failed to parse preferences: {e}")
20 })?;
21
22 log::info!("Successfully loaded preferences");
23 Ok(preferences)
24}Commands are registered in a central bindings.rs module:
1use tauri_specta::{collect_commands, Builder};
2
3pub fn generate_bindings() -> Builder<tauri::Wry> {
4 use crate::commands::{notifications, preferences, quick_pane, recovery};
5
6 Builder::<tauri::Wry>::new().commands(collect_commands![
7 preferences::greet,
8 preferences::load_preferences,
9 preferences::save_preferences,
10 notifications::send_native_notification,
11 recovery::save_emergency_data,
12 recovery::load_emergency_data,
13 recovery::cleanup_old_recovery_files,
14 quick_pane::show_quick_pane,
15 quick_pane::dismiss_quick_pane,
16 quick_pane::toggle_quick_pane,
17 quick_pane::get_default_quick_pane_shortcut,
18 quick_pane::update_quick_pane_shortcut,
19 ])
20}In debug builds on desktop, tauri-specta writes a bindings.ts file to src/lib/:
// In lib.rs — runs at app startup in debug mode
#[cfg(all(debug_assertions, desktop))]
bindings::export_ts_bindings();The generated file exposes fully typed functions. Calling a command from the frontend:
1import { commands } from "./lib/tauri-bindings";
2
3const result = await commands.loadPreferences();
4if (result.status === "ok") {
5 const savedLanguage = result.data.language;
6 await initializeLanguage(savedLanguage);
7 await buildAppMenu();
8}result.data is typed as AppPreferences — the same struct from Rust, mapped to TypeScript by specta. Rename load_preferences in Rust, regenerate bindings, and TypeScript compilation fails. The mismatch is caught before it reaches users.
Shared Types with specta
The AppPreferences struct derives specta::Type alongside the usual serde traits:
1#[derive(Debug, Clone, Serialize, Deserialize, Type)]
2pub struct AppPreferences {
3 pub theme: String,
4 /// Global shortcut for quick pane (e.g., "CommandOrControl+Shift+.")
5 /// If None, uses the default shortcut
6 pub quick_pane_shortcut: Option<String>,
7 /// User's preferred language (e.g., "en", "es", "de")
8 /// If None, uses system locale detection
9 pub language: Option<String>,
10}
11
12impl Default for AppPreferences {
13 fn default() -> Self {
14 Self {
15 theme: "system".to_string(),
16 quick_pane_shortcut: None,
17 language: None,
18 }
19 }
20}Enums work the same way. The RecoveryError type is a tagged union in Rust:
1#[derive(Debug, Clone, Serialize, Deserialize, Type)]
2#[serde(tag = "type")]
3pub enum RecoveryError {
4 FileNotFound,
5 ValidationError { message: String },
6 DataTooLarge { max_bytes: u32 },
7 IoError { message: String },
8 ParseError { message: String },
9}specta maps this to a TypeScript discriminated union — the frontend pattern-matches on error.type with full IDE autocomplete and exhaustiveness checking.
Rust Backend: Safety Patterns
Atomic File Writes
Both save_preferences and save_emergency_data use the same atomic write pattern — write to a temp file first, then rename. A crash between write and rename leaves the original file intact:
1pub async fn save_preferences(app: AppHandle, preferences: AppPreferences) -> Result<(), String> {
2 validate_theme(&preferences.theme)?;
3
4 let prefs_path = get_preferences_path(&app)?;
5 let json_content = serde_json::to_string_pretty(&preferences)
6 .map_err(|e| format!("Failed to serialize preferences: {e}"))?;
7
8 // Write to a temporary file first, then rename (atomic operation)
9 let temp_path = prefs_path.with_extension("tmp");
10
11 std::fs::write(&temp_path, json_content)
12 .map_err(|e| format!("Failed to write preferences file: {e}"))?;
13
14 if let Err(rename_err) = std::fs::rename(&temp_path, &prefs_path) {
15 // Clean up the temp file to avoid leaving orphaned files on disk
16 if let Err(remove_err) = std::fs::remove_file(&temp_path) {
17 log::warn!("Failed to remove temp file after rename failure: {remove_err}");
18 }
19 return Err(format!("Failed to finalize preferences file: {rename_err}"));
20 }
21
22 log::info!("Successfully saved preferences to {prefs_path:?}");
23 Ok(())
24}Input Validation in Rust
User-supplied filenames go through a validation gate before any filesystem access. The regex is compiled once with LazyLock:
1pub static FILENAME_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
2 Regex::new(r"^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9]+)?$")
3 .expect("Failed to compile filename regex pattern")
4});
5
6pub fn validate_filename(filename: &str) -> Result<(), String> {
7 if filename.is_empty() {
8 return Err("Filename cannot be empty".to_string());
9 }
10 if filename.chars().count() > 100 {
11 return Err("Filename too long (max 100 characters)".to_string());
12 }
13 if !FILENAME_PATTERN.is_match(filename) {
14 return Err(
15 "Invalid filename: only alphanumeric characters, dashes, underscores, and dots allowed"
16 .to_string(),
17 );
18 }
19 Ok(())
20}The save_emergency_data command calls this before touching the filesystem:
1pub async fn save_emergency_data(
2 app: AppHandle,
3 filename: String,
4 data: Value,
5) -> Result<(), RecoveryError> {
6 validate_filename(&filename)
7 .map_err(|e| RecoveryError::ValidationError { message: e })?;
8
9 let json_content = serde_json::to_string_pretty(&data).map_err(|e| {
10 RecoveryError::ParseError { message: e.to_string() }
11 })?;
12
13 // Validate size (10MB limit)
14 if json_content.len() > MAX_RECOVERY_DATA_BYTES as usize {
15 return Err(RecoveryError::DataTooLarge { max_bytes: MAX_RECOVERY_DATA_BYTES });
16 }
17
18 // ... atomic write as above
19}Path traversal attempts fail at validate_filename. The size limit prevents the recovery directory from growing unbounded.
Compile-Time Platform Branching
Tauri v2's #[cfg(desktop)] / #[cfg(mobile)] attributes branch platform behavior without runtime overhead. The quick pane initialization:
1pub fn init_quick_pane(app: &AppHandle) -> Result<(), String> {
2 #[cfg(target_os = "macos")]
3 {
4 return init_quick_pane_macos(app);
5 }
6
7 #[cfg(all(desktop, not(target_os = "macos")))]
8 {
9 return init_quick_pane_standard(app);
10 }
11
12 #[cfg(mobile)]
13 {
14 let _ = app;
15 Ok(())
16 }
17}On macOS, the quick pane is an NSPanel — a native floating panel that sits above fullscreen apps and doesn't steal focus from other applications. On Linux and Windows, it's a standard WebviewWindowBuilder. On Android, the function is a no-op. The Rust compiler eliminates unused branches entirely.
The Cargo.toml mirrors this at the dependency level:
1# Desktop-only plugins
2[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
3tauri-plugin-single-instance = "2"
4tauri-plugin-updater = "2"
5tauri-plugin-window-state = "2"
6
7# macOS-only: NSPanel for native panel behavior (fullscreen overlay, click-outside dismiss)
8[target.'cfg(target_os = "macos")'.dependencies]
9tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }Mobile builds don't pull in single-instance or updater plugins. The binary stays small.
Release Binary Optimization
1[profile.release]
2codegen-units = 1 # Better LLVM optimization (slower build, smaller binary)
3lto = true # Link-time optimizations
4opt-level = "s" # Optimize for size over speed
5panic = "abort" # Don't include panic unwinding code
6strip = true # Remove debug symbolsThe Gitlalchemy desktop installer is 3.2 MB on Linux. A minimal Electron app ships Chromium — typically 150-350 MB.
Frontend Architecture
Entry Point and App Initialization
main.tsx sets up TanStack Query at the root:
1import { QueryClientProvider } from "@tanstack/react-query";
2import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
3import ReactDOM from "react-dom/client";
4import App from "./App";
5import { queryClient } from "./lib/query-client";
6
7ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
8 <QueryClientProvider client={queryClient}>
9 <App />
10 <ReactQueryDevtools initialIsOpen={false} />
11 </QueryClientProvider>
12);App.tsx runs startup tasks in a single useEffect — language initialization, menu construction, recovery file cleanup, and the auto-updater check:
1function App() {
2 useEffect(() => {
3 initAnalytics();
4 initializeCommandSystem();
5
6 const initLanguageAndMenu = async () => {
7 try {
8 const result = await commands.loadPreferences();
9 const savedLanguage = result.status === "ok" ? result.data.language : null;
10 await initializeLanguage(savedLanguage);
11 await buildAppMenu();
12 setupMenuLanguageListener();
13 } catch (error) {
14 logger.warn("Failed to initialize language or menu", { error });
15 }
16 };
17
18 initLanguageAndMenu();
19
20 cleanupOldFiles().catch((error) => {
21 logger.warn("Failed to cleanup old recovery files", { error });
22 });
23
24 // Check for updates 5 seconds after app loads
25 const updateTimer = setTimeout(checkForUpdates, 5000);
26 return () => clearTimeout(updateTimer);
27 }, []);
28
29 return (
30 <ErrorBoundary>
31 <ThemeProvider>
32 <RouterProvider router={router} />
33 </ThemeProvider>
34 </ErrorBoundary>
35 );
36}File-Based Routing with TanStack Router
Routing uses TanStack Router v1 with auth guards at the route level:
1const rootRoute = createRootRoute({
2 component: Outlet,
3 beforeLoad: ({ location }) => {
4 if (location.pathname === "/") {
5 throw redirect({ to: "/login" });
6 }
7 },
8});
9
10const loginRoute = createRoute({
11 getParentRoute: () => rootRoute,
12 path: "/login",
13 component: LoginScreen,
14 beforeLoad: () => {
15 const session = selectActiveSession(useSessionStore.getState());
16 if (session) {
17 throw redirect({ to: "/workspace" });
18 }
19 },
20});
21
22const workspaceRoute = createRoute({
23 getParentRoute: () => rootRoute,
24 path: "/workspace",
25 component: MainWindow,
26 beforeLoad: () => {
27 const session = selectActiveSession(useSessionStore.getState());
28 if (!session) {
29 throw redirect({ to: "/login" });
30 }
31 },
32});Auth checks are synchronous — useSessionStore.getState() reads the Zustand store directly without a hook, so there's no render cycle involved in the guard.
Three-Layer State Management
State lives in exactly one of three places: useState for ephemeral local UI, Zustand for shared application state, and TanStack Query for server state.
The session store manages multi-account GitLab sessions with persistence:
1export interface GitLabSession {
2 url: string // e.g. "https://gitlab.com"
3 token: string // Personal Access Token
4 username?: string
5 avatarUrl?: string
6}
7
8export const useSessionStore = create<SessionState>()(
9 persist(
10 (set, get) => ({
11 sessions: [],
12 activeIndex: 0,
13
14 addSession: (session) => set(state => {
15 const existing = state.sessions.findIndex(s => s.url === session.url)
16 if (existing >= 0) {
17 // Update existing session for this URL and make it active
18 const sessions = [...state.sessions]
19 sessions[existing] = { ...sessions[existing], ...session }
20 return { sessions, activeIndex: existing }
21 }
22 return {
23 sessions: [...state.sessions, session],
24 activeIndex: state.sessions.length,
25 }
26 }),
27
28 signOut: () => {
29 const { removeSession, activeIndex } = get()
30 removeSession(activeIndex)
31 },
32 }),
33 {
34 name: 'gitlab-sessions',
35 partialize: (state) => ({ sessions: state.sessions, activeIndex: state.activeIndex }),
36 version: 1,
37 }
38 )
39)
40
41export const selectActiveSession = (state: SessionState): GitLabSession | null =>
42 state.sessions[state.activeIndex] ?? nullThe partialize option persists only the session data — not loading states or action functions. The version: 1 field enables migration if the store shape changes.
The filter store uses a factory pattern so every list screen gets its own isolated store instance without duplicating logic:
1export function createScreenStore<T>(options: ScreenStoreOptions<T>) {
2 return create<ScreenStoreState<T>>()((set, get) => ({
3 items: [] as T[],
4 page: 1,
5 loading: false,
6 filters: buildInitialFilters(options.filters),
7 hasMore: true,
8 error: null,
9
10 setFilter: (key, value) =>
11 set(state => ({ filters: { ...state.filters, [key]: value }, page: 1, items: [] })),
12
13 fetchItems: async (refresh = false) => {
14 const state = get()
15 if (state.loading) return
16 if (!state.hasMore && !refresh) return
17
18 const page = refresh ? 1 : state.page
19 set({ loading: true, error: null })
20
21 try {
22 const result = await options.queryFn(state.filters, { page, hasMore: state.hasMore })
23 const hasMore = result.length >= 20
24
25 if (refresh || page === 1) {
26 set({ items: result, page: 2, hasMore, loading: false })
27 } else {
28 set(s => ({ items: [...s.items, ...result], page: s.page + 1, hasMore, loading: false }))
29 }
30 } catch (e) {
31 set({ error: e instanceof Error ? e.message : 'An error occurred', loading: false })
32 }
33 },
34 }))
35}The GitLab API Client Package
@repo/api-client is a standalone package with no Tauri dependency — it works identically on desktop, Android, and in tests. It wraps ky with a typed class:
1export class GitLabClient {
2 private readonly http: KyInstance
3
4 constructor(session: GitLabSession) {
5 this.http = ky.create({
6 prefixUrl: `${session.url}/api/v4`,
7 headers: {
8 'PRIVATE-TOKEN': session.token,
9 'Content-Type': 'application/json',
10 },
11 retry: { limit: 1, methods: ['get'] },
12 })
13 }
14
15 readonly Users = {
16 current: () => this.http.get('user').json<GitLabUser>(),
17 show: (userId: number, params?: Record<string, unknown>) =>
18 this.http.get(`users/${userId}`, { searchParams: params as Record<string, string> })
19 .json<GitLabUser>(),
20 projects: (userId: number, params?: PaginationParams) =>
21 this.http.get(`users/${userId}/projects`, { searchParams: params as Record<string, string> })
22 .json<GitLabProject[]>(),
23 }
24
25 // Projects, Issues, MergeRequests, Pipelines, Jobs, Groups...
26}React hooks wrap the client methods through a stable useRef pattern that avoids recreating the client on every render:
1function useGitLabClient(session: GitLabSession | null): GitLabClient | null {
2 const ref = useRef<{ session: GitLabSession | null; client: GitLabClient | null }>({
3 session: null,
4 client: null,
5 })
6 if (ref.current.session !== session) {
7 ref.current.session = session
8 ref.current.client = session ? new GitLabClient(session) : null
9 }
10 return ref.current.client
11}
12
13export function useGitLabQuery<T>(
14 keys: readonly unknown[],
15 fetcher: (client: GitLabClient) => Promise<T>,
16 session: GitLabSession | null,
17 options?: Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn' | 'enabled'> & { enabled?: boolean },
18) {
19 const client = useGitLabClient(session)
20 const { enabled: callerEnabled, ...restOptions } = options ?? {}
21 return useQuery<T>({
22 queryKey: keys,
23 queryFn: () => fetcher(client!),
24 staleTime: 60_000,
25 gcTime: 60_000,
26 ...restOptions,
27 enabled: callerEnabled !== undefined ? callerEnabled && !!session : !!session,
28 })
29}Consuming a GitLab API endpoint from a screen component is a one-liner:
1export function useCurrentUser(session: GitLabSession | null) {
2 return useGitLabQuery(['currentUser'], c => c.Users.current(), session)
3}
4
5export function useProjects(session: GitLabSession | null, params?: PaginationParams) {
6 return useGitLabQuery(['projects', params], c => c.Projects.all(params), session)
7}Platform Detection in TypeScript
The TypeScript side mirrors the Rust platform branching with a cached detection utility:
1import { type Platform, platform } from "@tauri-apps/plugin-os";
2
3export type AppPlatform = "macos" | "windows" | "linux";
4
5let cachedPlatform: AppPlatform | null = null;
6
7function mapPlatform(p: Platform): AppPlatform {
8 if (p === "macos") return "macos";
9 if (p === "windows") return "windows";
10 return "linux";
11}
12
13export function getPlatform(): AppPlatform {
14 if (cachedPlatform === null) {
15 try {
16 cachedPlatform = mapPlatform(platform());
17 } catch {
18 // Fallback if platform() fails (e.g., in non-Tauri environment during tests)
19 logger.warn("Platform detection failed, defaulting to macOS");
20 cachedPlatform = "macos";
21 }
22 }
23 return cachedPlatform;
24}
25
26export function usePlatform(): AppPlatform { return getPlatform(); }
27export function useIsMacOS(): boolean { return usePlatform() === "macos"; }
28export function useIsWindows(): boolean { return usePlatform() === "windows"; }
29export function useIsLinux(): boolean { return usePlatform() === "linux"; }platform() from @tauri-apps/plugin-os is synchronous and cached at module level. The catch block matters — Vitest runs in jsdom, not a Tauri context, so platform() throws. The fallback keeps tests runnable without mocking the entire Tauri API.
Vite Configuration
The Vite config handles two entry points (main app and quick pane) and splits vendor chunks:
1export default defineConfig(async () => ({
2 define: {
3 __APP_VERSION__: JSON.stringify(packageJson.version),
4 },
5 plugins: [reactSWC(), tailwindcss()],
6 resolve: {
7 alias: { "@": path.resolve(__dirname, "./src") },
8 },
9 build: {
10 rollupOptions: {
11 input: {
12 main: resolve(__dirname, "index.html"),
13 "quick-pane": resolve(__dirname, "quick-pane.html"),
14 },
15 output: {
16 manualChunks: {
17 "vendor-react": ["react", "react-dom"],
18 "vendor-router": ["@tanstack/react-router"],
19 "vendor-query": ["@tanstack/react-query"],
20 "vendor-ui": [
21 "@radix-ui/react-dialog",
22 "@radix-ui/react-dropdown-menu",
23 "@radix-ui/react-select",
24 "@radix-ui/react-popover",
25 "@radix-ui/react-scroll-area",
26 ],
27 "vendor-markdown": [
28 "react-markdown", "remark-gfm", "remark-emoji",
29 "rehype-raw", "react-syntax-highlighter",
30 ],
31 "vendor-i18n": ["i18next", "react-i18next"],
32 },
33 },
34 },
35 },
36 clearScreen: false,
37 server: {
38 port: 1420,
39 strictPort: true,
40 watch: { ignored: ["**/src-tauri/**"] },
41 },
42}));clearScreen: false preserves Rust compiler errors in the terminal. strictPort: true makes Tauri fail fast if port 1420 is in use, rather than silently picking another port that tauri.conf.json isn't pointing at.
CI Pipeline
The GitLab CI pipeline runs lint, typecheck, and tests on every push. All three jobs run in parallel in a single check stage:
1stages: [check]
2
3variables:
4 BUN_VERSION: "1.3.6"
5
6default:
7 image: node:20-slim
8 before_script:
9 - npm install -g bun@$BUN_VERSION --silent
10 - bun install --frozen-lockfile
11
12.cache: &cache
13 cache:
14 key:
15 files: [bun.lock]
16 paths:
17 - node_modules/
18 - apps/desktop/node_modules/
19 - packages/api-client/node_modules/
20 - packages/state/node_modules/
21
22lint:
23 stage: check
24 <<: *cache
25 script:
26 - cd apps/desktop && bunx biome lint src/
27
28typecheck:
29 stage: check
30 <<: *cache
31 script:
32 - cd apps/desktop && bunx tsc --noEmit
33
34test:
35 stage: check
36 <<: *cache
37 script:
38 - cd apps/desktop && bun run test:coverage
39 coverage: '/All files\s*\|\s*([\d.]+)/'
40 artifacts:
41 reports:
42 coverage_report:
43 coverage_format: cobertura
44 path: apps/desktop/coverage/cobertura-coverage.xml
45 expire_in: 7 daysThe coverage regex extracts the overall line coverage percentage for GitLab's badge system. Rust compilation is not in this pipeline — it requires platform-specific runners and is handled locally via bun run tauri:build for releases.
Full Stack Summary
- Tauri v2 + Rust 1.82 — native shell, IPC, platform APIs
- React 19 + TypeScript 5.9 — UI layer
- Vite 7 + @vitejs/plugin-react-swc — dev server and bundler
- TanStack Query v5 — server state and caching
- TanStack Router v1 — type-safe routing with auth guards
- Zustand v5 — client state (sessions, preferences, filters)
- tauri-specta — auto-generated TypeScript bindings from Rust
- specta — Rust-to-TypeScript type mapping
- shadcn/ui v4 + Tailwind CSS v4 + Radix UI — component library
- Turborepo + Bun — monorepo and package management
- Biome — linter and formatter
- Vitest + @testing-library/react — unit and component tests
- ast-grep — custom lint rules enforcing state layer boundaries
- Lefthook — pre-commit hooks
The source is on GitLab: gitlab.com/thomas.pedot1/tauri-vite-gitlab-client.
Gitlalchemy is available for Android (including F-Droid), Linux, macOS, and Windows. gitalchemy.app