docs/architecture/adapters.md
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:
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");
// ...
}
if (isDesktop) scattered throughout the codebaseWhen adding a new backend command:
adapters/tauri/index.ts using tauriInvokeadapters/web/index.ts COMMANDS mapadapters/web/index.ts using invokeExample:
// 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:
isDesktop before callingimport { isDesktop } from "@/adapters";
if (isDesktop) {
const { open } = await import("@tauri-apps/plugin-dialog");
const filePath = await open({
filters: [{ name: "CSV", extensions: ["csv"] }],
});
// ...
}