When Manual Fails: Building a Mac Backup Daemon with Tauri and TypeScript
When Manual Fails: Building a Mac Backup Daemon with Tauri and TypeScript
Why do we spend dozens of hours automating a task that takes thirty seconds to do manually?
Every time I pop an SD card into my Mac, I tell myself I'll just drag and drop the files before clicking eject. It is a simple, deterministic process. Yet, three days later, I inevitably realize I haven't backed up a single photo. The card is still mounted, leaving me one clumsy coffee spill away from losing an entire weekend's worth of raw files.
I needed a tool that just worked. Something to handle one-way directory copying and verify the transfer via hashes. It also needed to watch the directory for changes and kick the external drive out of the operating system the moment the job finishes.
Problem Definition
I am guilty of relying on makeshift bash scripts tied to brittle cron jobs. They work until they don't. A silent failure in a background script is a data loss event waiting to happen. I wanted a proper application with visibility into the sync state and a scheduling mechanism, alongside reliable external drive monitoring.
If we are building a Mac-exclusive tool, the purist approach is Swift. But I haven't touched Swift in years, and my muscle memory is entirely wired for the web ecosystem.
So, I looked at hybrid frameworks. Electron was the immediate, familiar choice. I quickly discarded it. Running a full instance of Chromium just to watch a directory copy files feels fundamentally irresponsible.
I pivoted to Tauri. It pairs a Rust backend with a web frontend, relying on the OS's native webview. The promise is a native-feeling app with a fraction of the memory footprint.
I also decided to lean heavily into AI for this project. I've been experimenting with "vibe coding," letting LLMs generate the bulk of the logic while I act as the orchestrator. I expected this to be a breeze. I was wrong.
AI is fantastic at generating discrete functions. It is terrible at designing the harness. The moment you introduce IPC (Inter-Process Communication) between a Rust backend and a TypeScript frontend, models start hallucinating APIs that don't exist. I cycled through Gemini and Claude before finally settling into Codex, which somehow grasped the Tauri bridging logic with fewer catastrophic errors.
Solution
The architectural strategy became clear: let Rust handle the heavy lifting of the operating system interactions. Rust monitors the directories and calculates the xxhash for file verification (because MD5 is archaic and SHA-256 is overkill for simple file integrity) before issuing the unmount commands to MacOS.
TypeScript and React handle the state machine and scheduling, along with the user interface.
Here is where reality set in. I expected Tauri to sip memory like a monk on a fast. My app idles at around 170MB. It is significantly better than Electron's half-gigabyte baseline, but a humbling reminder that bridging a web UI to system threads is never entirely free.
Despite the memory overhead, the system works. Let's look at how the TypeScript implementation coordinates this chaos, providing a stable harness for the Rust core.
Implementation
The most critical part of this system is the bridge between the UI and the file system. We need strong typing to ensure the payloads we send across the Tauri IPC boundary match what the Rust backend expects.
1. Defining the Domain Models
First, I defined the core types. I don't want arbitrary strings flying across the IPC bridge.
// src/types/sync.ts
export type SyncStatus = 'idle' | 'scanning' | 'copying' | 'verifying' | 'completed' | 'error';
export interface SyncTask {
id: string;
sourceDirectory: string;
targetDirectory: string;
status: SyncStatus;
progress: number;
filesTotal: number;
filesCopied: number;
errorMessage?: string;
}
export interface SyncConfig {
autoUnmount: boolean;
scheduleCron?: string;
verifyHashes: boolean;
}
export interface FileEventPayload {
taskId: string;
fileName: string;
bytesCopied: number;
totalBytes: number;
}2. The API Layer (Tauri Invoke)
Next, we need a service layer to talk to Rust. Tauri provides the invoke function for standard request/response calls. I wrap these in typed asynchronous functions to hide the IPC implementation details from the React components.
// src/services/syncService.ts
import { invoke } from '@tauri-apps/api/core';
import { SyncTask, SyncConfig } from '../types/sync';
export class SyncService {
/**
* Initiates a one-way sync from source to target.
*/
static async startSync(source: string, target: string, config: SyncConfig): Promise<string> {
try {
// Returns a unique task ID from the Rust backend
const taskId = await invoke<string>('start_directory_sync', {
sourcePath: source,
targetPath: target,
autoUnmount: config.autoUnmount,
verify: config.verifyHashes
});
return taskId;
} catch (error) {
console.error('Failed to start sync:', error);
throw new Error(`Sync initiation failed: ${error}`);
}
}
/**
* Requests the OS to cleanly unmount the volume associated with the path.
*/
static async unmountDrive(volumePath: string): Promise<boolean> {
try {
const result = await invoke<boolean>('unmount_external_drive', {
path: volumePath
});
return result;
} catch (error) {
console.error(`Unmount failed for ${volumePath}:`, error);
return false;
}
}
}I genuinely don't know how I ever lived without explicit error boundaries at the IPC level. In early iterations, the AI kept trying to catch errors silently in the UI layer. If the Rust backend throws an EPERM (permission denied) because MacOS decided your app doesn't have disk access, you need that error to surface violently and immediately in development.
3. Event-Driven State Management
Polling is a terrible way to track file copy progress. Instead, the Rust backend emits events via Tauri's event system. We need a React hook to subscribe to these events and update our UI state.
// src/hooks/useSyncWatcher.ts
import { useState, useEffect, useCallback } from 'react';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { SyncTask, FileEventPayload } from '../types/sync';
export function useSyncWatcher(initialTaskId: string | null) {
const [taskState, setTaskState] = useState<Partial<SyncTask>>({
status: 'idle',
progress: 0,
filesCopied: 0,
});
useEffect(() => {
if (!initialTaskId) return;
let unlistenProgress: UnlistenFn;
let unlistenComplete: UnlistenFn;
let unlistenError: UnlistenFn;
const setupListeners = async () => {
// Listen for chunk copy progress
unlistenProgress = await listen<FileEventPayload>('sync-progress', (event) => {
if (event.payload.taskId !== initialTaskId) return;
setTaskState(prev => ({
...prev,
status: 'copying',
progress: (event.payload.bytesCopied / event.payload.totalBytes) * 100
}));
});
// Listen for the verification phase
unlistenComplete = await listen<{ taskId: string, hashMatch: boolean }>('sync-complete', (event) => {
if (event.payload.taskId !== initialTaskId) return;
setTaskState(prev => ({
...prev,
status: event.payload.hashMatch ? 'completed' : 'error',
progress: 100,
errorMessage: event.payload.hashMatch ? undefined : 'Hash verification failed'
}));
});
// Listen for fatal backend errors
unlistenError = await listen<{ taskId: string, message: string }>('sync-error', (event) => {
if (event.payload.taskId !== initialTaskId) return;
setTaskState(prev => ({
...prev,
status: 'error',
errorMessage: event.payload.message
}));
});
};
setupListeners();
return () => {
if (unlistenProgress) unlistenProgress();
if (unlistenComplete) unlistenComplete();
if (unlistenError) unlistenError();
};
}, [initialTaskId]);
return taskState;
}This hook is the connective tissue of the application. It isolates the Tauri event subscription logic from the rendering logic. When a file is copying, Rust fires thousands of sync-progress events. React batches these state updates, keeping the UI responsive without overwhelming the render cycle.
4. Tying it Together in the UI
Finally, we assemble the dashboard. This is where the configuration for one-way sync and scheduling, as well as auto-unmount, is defined and passed down to our system hooks.
// src/components/SyncDashboard.tsx
import React, { useState } from 'react';
import { SyncService } from '../services/syncService';
import { useSyncWatcher } from '../hooks/useSyncWatcher';
import { SyncConfig } from '../types/sync';
export const SyncDashboard: React.FC = () => {
const [source, setSource] = useState<string>('/Volumes/SD_CARD/DCIM');
const [target, setTarget] = useState<string>('/Users/username/Backups');
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [config, setConfig] = useState<SyncConfig>({
autoUnmount: true,
verifyHashes: true
});
const syncState = useSyncWatcher(activeTaskId);
const handleStartSync = async () => {
try {
const taskId = await SyncService.startSync(source, target, config);
setActiveTaskId(taskId);
} catch (error) {
alert('Failed to initialize synchronization. Check directory permissions.');
}
};
return (
<div className="p-6 max-w-2xl mx-auto bg-slate-900 text-white rounded-xl shadow-lg">
<h1 className="text-2xl font-bold mb-6">SyncWatcher Daemon</h1>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium mb-1">Source Directory (External)</label>
<input
value={source}
onChange={e => setSource(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded p-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Target Directory (Local)</label>
<input
value={target}
onChange={e => setTarget(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded p-2 text-sm"
/>
</div>
<div className="flex items-center space-x-3 mt-4">
<label className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={config.autoUnmount}
onChange={e => setConfig({...config, autoUnmount: e.target.checked})}
className="rounded bg-slate-800 border-slate-700"
/>
<span>Auto-unmount on completion</span>
</label>
<label className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={config.verifyHashes}
onChange={e => setConfig({...config, verifyHashes: e.target.checked})}
className="rounded bg-slate-800 border-slate-700"
/>
<span>Verify with xxhash</span>
</label>
</div>
</div>
<button
onClick={handleStartSync}
disabled={syncState.status === 'copying' || syncState.status === 'verifying'}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2 px-4 rounded transition-colors"
>
{syncState.status === 'idle' ? 'Start Backup' : `Status: ${syncState.status}`}
</button>
{syncState.status !== 'idle' && (
<div className="mt-6">
<div className="flex justify-between text-sm mb-1">
<span>Progress</span>
<span>{Math.round(syncState.progress || 0)}%</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${syncState.status === 'error' ? 'bg-red-500' : 'bg-green-500'}`}
style={{ width: `${syncState.progress}%` }}
></div>
</div>
{syncState.errorMessage && (
<p className="mt-2 text-sm text-red-400">Error: {syncState.errorMessage}</p>
)}
</div>
)}
</div>
);
};Conclusion
Building an application that interfaces directly with external hardware mounts and the file system forces you to confront the reality of edge cases. Files get locked, and SD cards get pulled out halfway through a read operation. Furthermore, operating systems can suddenly decide your daemon no longer has the privileges it had five minutes ago.
AI coding assistants write decent UI components and boilerplate Rust functions. They falter heavily when tasked with defining the state machine that links a MacOS hardware unmount event to a React view transition. That architecture, the "harness," remains fiercely a human domain.
I ended up with a slightly memory-heavy but reliable utility. The utility reliably watches directories and hashes files. It then executes copies before ejecting the drive. The friction of the manual process is gone. Sometimes, spending a weekend writing highly specific software to avoid thirty seconds of manual labor is exactly the right engineering decision.