Search selfstore
v1.8.21

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, stat, exists } from '@tauri-apps/plugin-fs';
import { save, open } from '@tauri-apps/plugin-dialog';
import { useDesktopFiles } from 'selfstore';

useDesktopFiles({ readFile, writeFile, 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, not AUTH_EXPIRED. Raising the reconnect gate would ask someone to re-pick a file that never moved.
  • 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.

The bridge

Any shell exposing these six calls 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>;
  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. A path it cannot read is “no backup yet”, not a failure.
writeFile Writes the (encrypted) backup on every debounced save.
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 Required by the bridge, so a shell’s filesystem module passes straight through unchanged. The target does not depend on it today: “is there a backup here” is answered by readFile.
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 });