Skip to content

Integration

use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime, State,
};
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("sync")
.invoke_handler(tauri::generate_handler![
sync_enable,
sync_disable,
sync_create_invite,
sync_join_invite,
sync_push,
sync_pull,
sync_status,
sync_last_cursor,
])
.setup(|app, api| {
// Initialize sync state
app.manage(SyncState::new(api.config()));
Ok(())
})
.build()
}
#[tauri::command]
async fn sync_enable(state: State<'_, SyncState>) -> Result<(), String>;
#[tauri::command]
async fn sync_create_invite(state: State<'_, SyncState>) -> Result<Invite, String>;
#[tauri::command]
async fn sync_join_invite(
state: State<'_, SyncState>,
invite: String,
) -> Result<(), String>;
#[tauri::command]
async fn sync_push(
state: State<'_, SyncState>,
data: Vec<u8>,
) -> Result<PushResult, String>;
#[tauri::command]
async fn sync_pull(
state: State<'_, SyncState>,
after_cursor: Option<u64>,
) -> Result<PullResult, String>;
#[tauri::command]
fn sync_status(state: State<'_, SyncState>) -> ConnectionStatus;
#[tauri::command]
fn sync_last_cursor(state: State<'_, SyncState>) -> u64;
// @0k-sync/tauri-plugin
export interface SyncPlugin {
enable(): Promise<void>;
disable(): Promise<void>;
createInvite(): Promise<Invite>;
joinInvite(invite: string): Promise<void>;
push(data: Uint8Array): Promise<PushResult>;
pull(afterCursor?: number): Promise<PullResult>;
status(): ConnectionStatus;
lastCursor(): number;
// Event listeners
on(event: 'connected', handler: (info: ConnectionInfo) => void): void;
on(event: 'disconnected', handler: (reason: string) => void): void;
on(event: 'blob-available', handler: (blob: BlobInfo) => void): void;
on(event: 'error', handler: (error: SyncError) => void): void;
}
export interface Invite {
qrCode: string; // Data URL for QR image
shortCode: string; // XXXX-XXXX-XXXX-XXXX
expiresAt: number; // Unix timestamp
}
export interface PushResult {
blobId: string;
cursor: number;
}
export interface PullResult {
blobs: Blob[];
hasMore: boolean;
maxCursor: number;
}
export interface Blob {
id: string;
cursor: number;
sender: string;
data: Uint8Array;
timestamp: number;
}
import { sync } from '@0k-sync/tauri-plugin';
// Initialize sync
await sync.enable();
// Create invite for new device
const invite = await sync.createInvite();
showQRCode(invite.qrCode);
// Or join existing sync group
await sync.joinInvite('XXXX-XXXX-XXXX-XXXX');
// Push data
const blob = new TextEncoder().encode(JSON.stringify(myData));
const result = await sync.push(blob);
console.log(`Synced at cursor ${result.cursor}`);
// Pull data
const { blobs, hasMore } = await sync.pull(lastKnownCursor);
for (const blob of blobs) {
const data = JSON.parse(new TextDecoder().decode(blob.data));
await mergeIntoLocalDb(data);
}
// Listen for real-time updates
sync.on('blob-available', async (info) => {
const { blobs } = await sync.pull(info.cursor - 1);
// Process new blob
});

⚠️ Critical Reality: On iOS and modern Android, background network connections (including QUIC/iroh) are killed within ~30 seconds of the app being backgrounded. You cannot rely on persistent connections for sync.

iOS applicationWillTerminate does NOT guarantee enough execution time to:

  1. Establish an iroh connection
  2. Perform a Noise handshake
  3. Upload a blob

The OS watchdog will kill your app before completing.

Design Assumption: Data generated while offline or just before closing will not sync until the next app launch. The UI must reflect this.

The UI must show sync state to avoid user confusion:

IndicatorMeaningUser Action
☁️✓Synced to relayNone needed
☁️⏳Pending sync (will sync on next launch)None; automatic
☁️✗Sync failed (will retry)Check connection
📴Offline (changes saved locally)Restore connection

Definition: Local changes that exist only on the device and haven’t synced to the relay.

Rules:

  1. Never block UI trying to sync on exit — you will annoy users or trigger OS watchdog
  2. Assume stranded commits — local changes may not sync until next launch
  3. Local-first always — save to local DB first, sync is opportunistic

The UI should never wait for sync to complete before showing changes:

// In your local-first app
async fn save_transaction(tx: Transaction, sync: &SyncClient, db: &LocalDb) {
// 1. Save locally FIRST (instant, always succeeds)
db.insert(&tx).await?;
// 2. Update UI immediately (optimistic)
emit_to_frontend("transaction_saved", &tx);
// 3. Queue for sync (fire-and-forget)
let blob = serialize(&tx);
match sync.push(&blob).await {
Ok(result) => {
// Mark as synced in local DB
db.mark_synced(tx.id, result.cursor).await?;
emit_to_frontend("sync_status", SyncStatus::Synced);
}
Err(_) => {
// Will retry on next app launch
emit_to_frontend("sync_status", SyncStatus::Pending);
}
}
}

Key Principle: The local database is the source of truth. Sync is opportunistic. Users should never lose data because sync failed.

Sync should be triggered on these application lifecycle events:

// Pseudocode - adapt to your framework (Tauri, Electron, React Native, etc.)
// On app startup
async fn on_app_start(sync_client: &SyncClient) {
// Initial sync on app start
sync_client.connect_and_pull().await;
}
// On app resumed (came to foreground)
async fn on_app_resume(sync_client: &SyncClient) {
// App came to foreground - pull new data, push pending
sync_client.sync_pending_then_pull().await;
}
// On app closing
async fn on_app_close(sync_client: &SyncClient) {
// App closing - DO NOT block waiting for sync!
// Just mark pending items; they'll sync next launch
sync_client.mark_pending_for_next_launch();
// Fire-and-forget attempt (may not complete)
let _ = tokio::time::timeout(
std::time::Duration::from_millis(500),
sync_client.quick_flush()
).await;
}
EventActionBlocking?
App launchConnect + push pending + pull newNo (background)
App resume (foreground)Push pending + pull newNo (background)
App pause (background)Mark state, fire-and-forget flushNo (500ms max)
User action (save, etc.)Save local, queue for sync, attempt pushNo
Manual “Sync Now” buttonFull sync cycleYes (with spinner)
FeatureReasonFuture Possibility
Background synciOS/Android kill background connectionsPush notifications
Push notifications for new dataRequires APNS/FCM integrationOptional plugin
Always-on syncNot possible on mobile without OS supportNone
Guaranteed sync on exitOS doesn’t allow itNone