Desktop shell (Tauri)
This is not a fifth destination. It is the disk file home made to work where it otherwise would not: inside a native webview.
The problem it answers
File System Access is Chromium-only, and inside a native webview it is usually absent altogether - macOS and Linux shells embed WebKit, which never shipped it. So wrapping your web app as a desktop app would lose the file mode and fall back to download-on-demand, in the one environment where writing a real file is easiest.
One call at start-up
Hand the shell’s own calls over once, before opening the store. With Tauri v2 the whole integration is the two plugin imports:
import { readFile, writeFile, rename, stat, exists } from '@tauri-apps/plugin-fs';
import { save, open } from '@tauri-apps/plugin-dialog';
import { useDesktopFiles } from 'selfstore';
useDesktopFiles({ readFile, writeFile, rename, stat, exists, save, open });
That is all of it. store.connectFile(), file: true in the
connect flow and every widget keep meaning
what they meant; they just write a path. Nothing else in a host changes.
The calls are injected, never imported, so a web build carries none of this and selfstore gains no runtime dependency on a desktop toolkit.
A path, not a handle
That is the whole difference against the browser target, and it is why the desktop experience is the better one:
- It outlives the session. A path survives a restart, an app update and a copy of the profile directory. The store reopens on its file with nothing asked of the user, where a browser handle needs a click to re-grant.
- A failed write is transient, never a lost grant. A full disk, an unplugged
volume or a file locked by another program raises
TARGET_WRITE_FAILED, notAUTH_EXPIRED. Raising the reconnect gate would ask someone to re-pick a file that never moved. - A save cannot truncate the last good copy, when the shell can move a
file. With
renamein the bridge (since 1.8.31), each save writes the backup beside the file, as<path>.tmp, then moves it over the previous one. A full disk or a crash mid-write leaves the last backup whole. Withoutrename, the save writes the path directly, as before. - The destination is ready before the file exists. A path chosen through the
save dialog names a file that is not there yet; refusing until it exists would
deadlock the very first save, which is what creates it. Reading it back before
anything was written answers “no backup there” rather than failing - but only
because
existssays the file is absent. A file that exists and cannot be read is a failure, never an empty destination (since 1.8.31).
The bridge
Any shell exposing these six calls, plus an optional seventh, works; Tauri is only the one with a copy-paste line.
interface DesktopFileBridge {
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, data: Uint8Array): Promise<void>;
rename?(from: string, to: string): Promise<void>; // optional, since 1.8.31
stat(path: string): Promise<{ mtime?: Date | number | null } | null>;
exists(path: string): Promise<boolean>;
save(options: { defaultPath?: string; filters?: DesktopDialogFilter[] }): Promise<string | null>;
open(options: {
multiple?: boolean;
filters?: DesktopDialogFilter[];
}): Promise<string | string[] | null>;
}
interface DesktopDialogFilter {
name: string;
extensions: string[];
}
| Call | What the target does with it |
|---|---|
readFile |
Reads the backup ZIP back, once exists has said the file is there. A read that fails on an existing file raises TARGET_UNAVAILABLE: an unreadable backup is never taken for a missing one. |
writeFile |
Writes the backup on every debounced save (encrypted when the store has a password). With rename, it writes <path>.tmp beside the file; without, the path itself. A failure raises TARGET_WRITE_FAILED. |
rename |
Optional (since 1.8.31). Moves <path>.tmp over the backup, so a save replaces the previous copy whole or not at all. A shell that predates it may leave it out and keeps writing in place. |
stat |
Only mtime is read, as the version marker; Date or epoch milliseconds both. A shell that cannot report one may leave it absent, and the store falls back to its own bookkeeping rather than treating it as a fault. |
exists |
Answers “is there a backup here” before every read. Only false means “no backup yet”; the target then reads nothing and treats the destination as empty. |
save |
The save dialog, for connecting and for re-picking. Resolves to the chosen path, or null when cancelled. |
open |
The open dialog, for adopting an existing backup. The array form is accepted so a host can pass its shell’s function unchanged; the first path is used. |
DesktopDialogFilter is the filter shape both dialogs take - the same
{ name, extensions } pair Tauri’s dialog plugin expects. selfstore passes its
own (Backup, zip) when it opens a dialog; you only name the type when you
call a dialog yourself.
Asking whether a shell is there
import { hasDesktopFiles } from 'selfstore';
hasDesktopFiles(); // true once useDesktopFiles() has been given a bridge
This is what the file destination asks itself, and a registered shell answers before the browser probe - unconditionally. Inside a native webview that probe is not merely negative but misleading, since WebKit never shipped the API; the honest capability there is the shell’s.
For one codebase shipping both web and desktop, it is also the flag your own
copy can branch on (“Choose a file” against “Download a backup”). Passing null
unregisters, which is what a test wants between cases:
useDesktopFiles(null);
On the advanced store
There is no separate desktop target to import. fileTarget from
selfstore/advanced routes connect, openExisting,
fromSession, isSupported and isOpenSupported through the registered shell:
import { fileTarget } from 'selfstore/advanced';
// After useDesktopFiles(...), this opens the shell's save dialog and keeps a path.
const target = await fileTarget.connect({ kv: cache.kv, fileName: 'my-app.zip' });
await store.attachTarget(target, { password });