This document describes the adapter system used to support multiple runtime environments (Desktop/Tauri and Web/REST API) with compile-time environment detection.
Wealthfolio runs in two environments:
- Desktop (Tauri): Uses Tauri's IPC to invoke Rust commands directly
- Web: Uses REST API calls to a backend server
The adapter system provides a unified interface that works identically in both environments, with the correct implementation selected at build time.
apps/frontend/src/adapters/
├── index.ts # Re-exports from default adapter (for TypeScript)
├── types.ts # Shared types for all adapters
├── tauri/
│ └── index.ts # Desktop/Tauri implementation
└── web/
└── index.ts # Web/REST API implementation
Vite's resolve.alias is configured to point @/adapters to either
adapters/tauri or adapters/web based on the BUILD_TARGET environment
variable:
// apps/frontend/vite.config.ts
const buildTarget = process.env.BUILD_TARGET || "tauri";
export default defineConfig({
resolve: {
alias: {
"@/adapters": path.resolve(
__dirname,
buildTarget === "tauri" ? "./src/adapters/tauri" : "./src/adapters/web",
),
},
},
});The package.json scripts set the appropriate BUILD_TARGET:
{
"scripts": {
"dev": "BUILD_TARGET=web vite",
"dev:tauri": "BUILD_TARGET=tauri vite",
"build": "BUILD_TARGET=web ... vite build",
"build:tauri": "BUILD_TARGET=tauri vite build"
}
}For TypeScript type-checking (which doesn't use Vite's aliases), index.ts
re-exports from the Tauri adapter by default:
// apps/frontend/src/adapters/index.ts
export * from "./tauri";This ensures TypeScript can resolve types correctly while the actual build uses the correct adapter.
All adapters export the same interface:
// Core exports
export const RUN_ENV: RunEnv; // "desktop" | "web"
export const isDesktop: boolean;
export const isWeb: boolean;
export const logger: Logger;
// Typed command functions (preferred pattern)
export const getAccounts: <T>() => Promise<T[]>;
export const syncBrokerData: () => Promise<void>;
export const getImportRuns: <T>(request?: ImportRunsRequest) => Promise<T[]>;
// ... more typed functions for each backend command
// Event listeners
export const listenDeepLink: (callback: EventCallback<string>) => Promise<UnlistenFn>;
export const listenNavigateToRoute: (callback: EventCallback<string>) => Promise<UnlistenFn>;
// ... more event listeners
// File operations
export const openFileSaveDialog: (content: string | Uint8Array | Blob, fileName: string) => Promise<boolean>;
export const openFolderDialog: () => Promise<string | null>;
// Types
export type { EventCallback, UnlistenFn, Logger, RunEnv };
export type { ExtractedAddon, InstalledAddon, AddonManifest, ... };Import typed functions from @/adapters and use them in services:
import {
logger,
getAccounts as getAccountsAdapter,
syncBrokerData as syncBrokerDataAdapter,
} from "@/adapters";
// Service wraps adapter with error handling
export const getAccounts = async (): Promise<Account[]> => {
try {
return await getAccountsAdapter<Account>();
} catch (error) {
logger.error("Error getting accounts.");
throw error;
}
};
// Check environment when needed
import { isDesktop } from "@/adapters";
if (isDesktop) {
// Desktop-specific code (e.g., file dialogs)
const { open } = await import("@tauri-apps/plugin-dialog");
// ...
}- Dead Code Elimination: Web builds don't include Tauri code and vice versa
- No Runtime Checks: Environment is determined at compile time
- Type Safety: Full TypeScript support with unified types
- Cleaner Code: No
if (isDesktop)scattered throughout the codebase - Smaller Bundles: Each build only includes the code it needs
When adding a new backend command:
- Add a typed function in
adapters/tauri/index.tsusingtauriInvoke - Add the REST API mapping in
adapters/web/index.tsCOMMANDS map - Add a matching typed function in
adapters/web/index.tsusinginvoke - Create a service function that wraps the adapter with error handling
Example:
// adapters/tauri/index.ts
export async function myNewCommand<T>(data: MyData): Promise<T> {
return tauriInvoke<T>("my_new_command", { data });
}
// adapters/web/index.ts
// 1. Add to COMMANDS map
const COMMANDS = {
// ...existing commands
my_new_command: { method: "POST", path: "/my-endpoint" },
};
// 2. Add typed function
export async function myNewCommand<T>(data: MyData): Promise<T> {
return invoke<T>("my_new_command", { data });
}
// services/my-service.ts
import { logger, myNewCommand as myNewCommandAdapter } from "@/adapters";
export const myNewCommand = async (data: MyData): Promise<Result> => {
try {
return await myNewCommandAdapter<Result>(data);
} catch (error) {
logger.error("Error in myNewCommand.");
throw error;
}
};Some features only work on desktop (e.g., file system dialogs). These should:
- Check
isDesktopbefore calling - Use dynamic imports for Tauri plugins
- Provide graceful fallbacks for web
import { isDesktop } from "@/adapters";
if (isDesktop) {
const { open } = await import("@tauri-apps/plugin-dialog");
const filePath = await open({
filters: [{ name: "CSV", extensions: ["csv"] }],
});
// ...
}