# Desktop shell (Tauri) Source: https://selfstore.dev/docs/desktop Packaging a web app as a desktop app used to lose the disk file home. Hand the shell its filesystem and dialog calls once - useDesktopFiles - and every file destination writes a real path. This is not a fifth destination. It is the [disk file home](https://selfstore.dev/docs/disk) 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: ```ts 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()`](https://selfstore.dev/docs/api-store), `file: true` in the [connect flow](https://selfstore.dev/docs/api-flows) and every [widget](https://selfstore.dev/docs/widgets) 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`](https://selfstore.dev/docs/errors), 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. ```ts interface DesktopFileBridge { readFile(path: string): Promise; writeFile(path: string, data: Uint8Array): Promise; stat(path: string): Promise<{ mtime?: Date | number | null } | null>; exists(path: string): Promise; save(options: { defaultPath?: string; filters?: DesktopDialogFilter[] }): Promise; open(options: { multiple?: boolean; filters?: DesktopDialogFilter[]; }): Promise; } 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 ```ts 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: ```ts useDesktopFiles(null); ``` ## On the advanced store There is no separate desktop target to import. `fileTarget` from [`selfstore/advanced`](https://selfstore.dev/docs/api-advanced) routes `connect`, `openExisting`, `fromSession`, `isSupported` and `isOpenSupported` through the registered shell: ```ts 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 }); ``` Map of this site for a model: https://selfstore.dev/llms.txt Every page in one file: https://selfstore.dev/llms-full.txt