Search selfstore
v1.8.37

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, not AUTH_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 rename in 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. Without rename, 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 exists says 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 });