spacedrive/docs/react/ui/platform.mdx
Jamie Pine ddcefe2495 docs
2025-11-14 21:40:49 -08:00

487 lines
11 KiB
Plaintext

---
title: Platform Abstraction
sidebarTitle: Platform
---
The platform abstraction layer enables the Spacedrive interface to run on multiple platforms (Web, Tauri, React Native) while accessing platform-specific features.
## Problem
Spacedrive runs on:
- **Desktop** via Tauri (native file pickers, native dialogs)
- **Web** via browser (limited native APIs)
- **Mobile** via React Native (different native APIs)
We need a single codebase that works everywhere without `if (isTauri)` checks scattered throughout.
## Solution
The `Platform` type and `usePlatform()` hook provide a clean abstraction:
```tsx
// Component doesn't know or care which platform it's on
function FilePicker() {
const platform = usePlatform();
const handlePickFile = async () => {
if (platform.openFilePickerDialog) {
const path = await platform.openFilePickerDialog({ multiple: false });
console.log('Selected:', path);
} else {
// Fallback for platforms without native picker
console.log('Use web file input instead');
}
};
return <button onClick={handlePickFile}>Pick File</button>;
}
```
## Platform Type
```tsx
type Platform = {
// Platform discriminator
platform: "web" | "tauri";
// Open native directory picker dialog (Tauri only)
openDirectoryPickerDialog?(opts?: {
title?: string;
multiple?: boolean;
}): Promise<string | string[] | null>;
// Open native file picker dialog (Tauri only)
openFilePickerDialog?(opts?: {
title?: string;
multiple?: boolean;
}): Promise<string | string[] | null>;
// Save file picker dialog (Tauri only)
saveFilePickerDialog?(opts?: {
title?: string;
defaultPath?: string;
}): Promise<string | null>;
// Open a URL in the default browser
openLink(url: string): void;
// Show native confirmation dialog
confirm(message: string, callback: (result: boolean) => void): void;
};
```
## Usage
### Setup
Wrap your app with `PlatformProvider`:
```tsx
import { PlatformProvider, Platform } from '@sd/interface';
// Tauri implementation
const tauriPlatform: Platform = {
platform: 'tauri',
openDirectoryPickerDialog: async (opts) => {
return await window.__TAURI__.dialog.open({
directory: true,
title: opts?.title,
multiple: opts?.multiple,
});
},
openFilePickerDialog: async (opts) => {
return await window.__TAURI__.dialog.open({
directory: false,
title: opts?.title,
multiple: opts?.multiple,
});
},
saveFilePickerDialog: async (opts) => {
return await window.__TAURI__.dialog.save({
title: opts?.title,
defaultPath: opts?.defaultPath,
});
},
openLink: (url) => {
window.__TAURI__.shell.open(url);
},
confirm: (message, callback) => {
window.__TAURI__.dialog.confirm(message).then(callback);
},
};
// Web implementation
const webPlatform: Platform = {
platform: 'web',
// No native dialogs
openLink: (url) => {
window.open(url, '_blank');
},
confirm: (message, callback) => {
callback(window.confirm(message));
},
};
function App() {
const platform = window.__TAURI__ ? tauriPlatform : webPlatform;
return (
<PlatformProvider platform={platform}>
<YourApp />
</PlatformProvider>
);
}
```
### Using in Components
```tsx
import { usePlatform } from '@sd/interface';
function AddLocationButton() {
const platform = usePlatform();
const handleAddLocation = async () => {
// Check if native picker is available
if (platform.openDirectoryPickerDialog) {
const path = await platform.openDirectoryPickerDialog({
title: 'Select folder to index',
multiple: false,
});
if (path && typeof path === 'string') {
// Create location with selected path
await createLocation({ path });
}
} else {
// Web fallback: show manual path input
showManualPathDialog();
}
};
return (
<button onClick={handleAddLocation}>
Add Location
</button>
);
}
```
### Opening External Links
```tsx
function HelpButton() {
const platform = usePlatform();
return (
<button onClick={() => platform.openLink('https://spacedrive.com/docs')}>
Open Documentation
</button>
);
}
```
### Confirmation Dialogs
```tsx
function DeleteButton({ itemName }: { itemName: string }) {
const platform = usePlatform();
const handleDelete = () => {
platform.confirm(
`Are you sure you want to delete "${itemName}"?`,
(confirmed) => {
if (confirmed) {
performDelete();
}
}
);
};
return <button onClick={handleDelete}>Delete</button>;
}
```
## Checking Platform Type
```tsx
function FeatureGate() {
const platform = usePlatform();
if (platform.platform === 'web') {
return <div>Web-specific UI</div>;
}
return <div>Desktop-specific UI</div>;
}
```
## Optional Methods Pattern
All platform-specific methods are **optional** (marked with `?`). This enables:
1. **Graceful degradation** - Components can check availability and provide fallbacks
2. **Type safety** - TypeScript enforces checking before calling
3. **Platform flexibility** - New platforms can implement only what they support
```tsx
// Good - checks availability
if (platform.openFilePickerDialog) {
await platform.openFilePickerDialog();
} else {
// Fallback for platforms without native picker
}
// Bad - will error at runtime on web
await platform.openFilePickerDialog(); // Type error!
```
## Adding New Platform Methods
To add a new platform capability:
1. Update the `Platform` type in `platform.tsx`
2. Implement for each platform (Tauri, Web, React Native)
3. Use with optional chaining in components
```tsx
// 1. Add to Platform type
type Platform = {
// ... existing methods
// New method
showNotification?(opts: {
title: string;
body: string;
}): void;
};
// 2. Implement for Tauri
const tauriPlatform: Platform = {
// ... existing methods
showNotification: (opts) => {
window.__TAURI__.notification.sendNotification(opts);
},
};
// 3. Implement for Web
const webPlatform: Platform = {
// ... existing methods
showNotification: (opts) => {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(opts.title, { body: opts.body });
}
},
};
// 4. Use in components
function NotifyButton() {
const platform = usePlatform();
const notify = () => {
platform.showNotification?.({
title: 'Hello',
body: 'World',
});
};
return <button onClick={notify}>Notify</button>;
}
```
## Best Practices
### Provide Fallbacks
Always have a fallback for platforms without the feature:
```tsx
// Good
const pickFile = async () => {
if (platform.openFilePickerDialog) {
return await platform.openFilePickerDialog();
} else {
// Show web file input or manual path entry
return await showWebFilePicker();
}
};
// Bad - feature just doesn't work on some platforms
const pickFile = async () => {
return await platform.openFilePickerDialog?.();
// Returns undefined on web, no fallback!
};
```
### Don't Check Platform String
Avoid checking `platform.platform` directly. Check method availability instead:
```tsx
// Good - feature detection
if (platform.openDirectoryPickerDialog) {
// Use native picker
}
// Bad - platform detection
if (platform.platform === 'tauri') {
// Assumes Tauri = has picker (maybe not in future)
}
```
### Keep Platform Logic Minimal
Platform-specific code should be minimal. Most logic should be platform-agnostic:
```tsx
// Good - only picker is platform-specific
async function addLocation() {
const path = await pickDirectory(); // Platform abstraction
const location = createLocationFromPath(path); // Platform-agnostic
await saveLocation(location); // Platform-agnostic
showSuccessMessage(); // Platform-agnostic
}
// Bad - too much platform-specific code
async function addLocationTauri() {
// Entire flow is Tauri-specific, can't reuse
}
async function addLocationWeb() {
// Duplicate logic for web
}
```
### Use Context, Not Props
Use `usePlatform()` hook instead of prop drilling:
```tsx
// Good
function DeepComponent() {
const platform = usePlatform(); // Available anywhere
}
// Bad
function Parent() {
return <Child platform={platform} />;
}
function Child({ platform }: { platform: Platform }) {
return <GrandChild platform={platform} />;
}
```
## Platform Implementations
### Tauri
```tsx
const tauriPlatform: Platform = {
platform: 'tauri',
openDirectoryPickerDialog: async (opts) => {
return await window.__TAURI__.dialog.open({
directory: true,
title: opts?.title,
multiple: opts?.multiple,
});
},
openFilePickerDialog: async (opts) => {
return await window.__TAURI__.dialog.open({
directory: false,
title: opts?.title,
multiple: opts?.multiple,
});
},
saveFilePickerDialog: async (opts) => {
return await window.__TAURI__.dialog.save({
title: opts?.title,
defaultPath: opts?.defaultPath,
});
},
openLink: (url) => {
window.__TAURI__.shell.open(url);
},
confirm: (message, callback) => {
window.__TAURI__.dialog.confirm(message).then(callback);
},
};
```
### Web
```tsx
const webPlatform: Platform = {
platform: 'web',
openLink: (url) => {
window.open(url, '_blank');
},
confirm: (message, callback) => {
callback(window.confirm(message));
},
// Native pickers not available
};
```
### React Native (Future)
```tsx
const rnPlatform: Platform = {
platform: 'mobile',
openDirectoryPickerDialog: async (opts) => {
// Use react-native-document-picker or similar
return await DocumentPicker.pickDirectory();
},
openLink: (url) => {
Linking.openURL(url);
},
confirm: (message, callback) => {
Alert.alert(
'Confirm',
message,
[
{ text: 'Cancel', onPress: () => callback(false) },
{ text: 'OK', onPress: () => callback(true) },
]
);
},
};
```
## Error Handling
```tsx
async function pickFile() {
const platform = usePlatform();
if (!platform.openFilePickerDialog) {
throw new Error('File picker not available on this platform');
}
try {
const path = await platform.openFilePickerDialog();
if (!path) {
// User cancelled
return null;
}
return path;
} catch (error) {
console.error('Failed to pick file:', error);
return null;
}
}
```
## Summary
The platform abstraction layer:
- **Single codebase** works on Web, Tauri, and React Native
- **Clean API** via `usePlatform()` hook
- **Type-safe** with optional methods
- **Graceful degradation** with feature detection
- **Minimal boilerplate** using React Context
- **Easy to extend** with new platform methods
Use it whenever you need platform-specific functionality like native dialogs, file pickers, or shell commands.