Skip to content
Guide

files

Sooner or later someone wants to get a file in or out of your app: import orders from a spreadsheet, export invoices as CSV, attach a receipt. @kitr/files gives you one service with three calls, open(), pick() and save(), that shows the best dialog the current platform has.

Chrome and Edge use the File System Access API; other browsers fall back to a hidden file input and a download link. Every picked file is a PickedFile with text(), bytes() and blob().

The guide follows Acme's billing screen at app.acme.com: Ada imports orders from a CSV, attaches receipts to an invoice, and exports invoices for Grace in accounting.

When to use it

Use @kitr/files when the person's own device is the source or destination. The content stays in the browser until your code sends it somewhere.

Use Uploads when the file must reach your server or object storage: progress, size limits, presigned URLs and scanning are its job. Pick with @kitr/files, then hand the Blob to an upload.

Install

kitr add files

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

Everything kitr add changes

What kitr add does:

  • Installs @kitr/files and adds it to packages in kitrrc.ts.
  • Adds () => import('@kitr/files/provider') to providers in kitrrc.ts.
  • Writes config/files.ts (shown under Configure).
  • Adds a ## kitr: files section to AGENTS.md: open, pick and save with useFileDialogs() from @kitr/files/react or app.container.use('files'), never build <input type="file"> or download links by hand, and expect a cancelled dialog to resolve to [] or null.

kitr add files asks no questions and adds no environment variables or scripts. No kit includes it.

kitr remove files reverses every step above and deletes config/files.ts, then lists the files that still import @kitr/files.

Configure

kitr add files writes this file:

config/files.ts
import { defineConfig } from '@kitr/files'

export default defineConfig({
  // Filters added to every open/save dialog.
  filters: [],
})
Option Default Description
filters [] File types added to every dialog, after the ones a call passes. Use it for your app's own document type.
filters[].name The label in the dialog's file-type menu, such as Acme projects.
filters[].extensions Extensions without the dot, such as ['acme']. Case, leading dots and spaces are ignored; '*' and empty strings are dropped.
driver the platform's driver, else the driver for the runtime Replace the dialogs. Tests pass createMemoryFilesDriver() here.
fallbackToWeb false In Electron, Tauri or Capacitor with no files driver, use the web driver inside the webview instead of throwing E_DRIVER_UNAVAILABLE.

The service picks its driver on first use: driver from config, then one a platform registered, then the web driver in a browser or an in-memory driver on the server. See Where it runs.

Acme has its own project format, so every dialog offers it:

config/files.ts
import { defineConfig } from '@kitr/files'

export default defineConfig({
  filters: [{ name: 'Acme projects', extensions: ['acme'] }],
})

Save your first file

Export Acme's open orders as a CSV with one button. useFileDialogs() returns the service's calls plus a busy flag:

app/components/orders/export-orders-button.tsx
import { useFileDialogs } from '@kitr/files/react'

const orders = [
  { id: 'ord_1042', customer: 'Ada', total: 129 },
  { id: 'ord_1043', customer: 'Grace', total: 80 },
]

export function ExportOrdersButton() {
  const { save, busy } = useFileDialogs()

  async function exportOrders() {
    const csv = ['id,customer,total', ...orders.map((o) => `${o.id},${o.customer},${o.total}`)].join('\n')
    const where = await save(csv, { name: 'acme-orders.csv' })
    if (where) console.log(`Saved to ${where}`)
  }

  return (
    <button type="button" disabled={busy} onClick={exportOrders}>
      Export orders
    </button>
  )
}

In Chrome or Edge, Export orders opens a save dialog with acme-orders.csv filled in. In Firefox and Safari the browser downloads it right away. The file holds:

id,customer,total
ord_1042,Ada,129
ord_1043,Grace,80

save() resolves to the saved name, a path on platforms that have one, or null on cancel.

Open a file and read it

pick() opens a dialog for exactly one file and resolves to a PickedFile, or null when the person cancels. Read its content with text():

app/components/orders/import-orders-button.tsx
import { useFileDialogs } from '@kitr/files/react'
import { useState } from 'react'

interface ImportedOrder {
  id: string
  customer: string
  total: number
}

function parseOrders(csv: string): ImportedOrder[] {
  const [, ...rows] = csv.trim().split('\n')
  return rows.map((row) => {
    const [id = '', customer = '', total = '0'] = row.split(',')
    return { id, customer, total: Number(total) }
  })
}

export function ImportOrdersButton() {
  const { pick, busy } = useFileDialogs()
  const [imported, setImported] = useState<ImportedOrder[]>([])

  async function importOrders() {
    const file = await pick({ filters: [{ name: 'Spreadsheets', extensions: ['csv'] }] })
    if (!file) return
    setImported(parseOrders(await file.text()))
  }

  return (
    <div>
      <button type="button" disabled={busy} onClick={importOrders}>
        Import orders
      </button>
      <p>{imported.length} orders imported</p>
    </div>
  )
}

Choose the exported file and the paragraph reads 2 orders imported. Nothing has left the browser; send the rows to your API with a mutation when the person confirms.

Open several files

open() resolves to an array. Pass multiple: true to allow more than one file:

app/components/invoices/attach-receipts.tsx
import type { PickedFile } from '@kitr/files'
import { useFileDialogs } from '@kitr/files/react'
import { useState } from 'react'

export function AttachReceipts({ invoiceId }: { invoiceId: string }) {
  const { open, busy } = useFileDialogs()
  const [receipts, setReceipts] = useState<PickedFile[]>([])

  async function attach() {
    const files = await open({
      multiple: true,
      title: `Receipts for ${invoiceId}`,
      filters: [
        { name: 'Images', extensions: ['png', 'jpg', 'jpeg'] },
        { name: 'PDF', extensions: ['pdf'] },
      ],
    })
    setReceipts((current) => [...current, ...files])
  }

  return (
    <div>
      <button type="button" disabled={busy} onClick={attach}>
        Attach receipts
      </button>
      <ul>
        {receipts.map((file) => (
          <li key={file.name}>
            {file.name} ({Math.round(file.size / 1024)} KB)
          </li>
        ))}
      </ul>
    </div>
  )
}

A cancelled dialog resolves to [], so the list stays as it was. Browsers ignore title; platform dialogs show it.

Filter by file type

Each filter is a label and a list of extensions. Yours come first, then those in config/files.ts:

app/lib/projects/open-project.ts
import { useFiles } from '@kitr/files'
import { app } from '#app/app'

// With `filters: [{ name: 'Acme projects', extensions: ['acme'] }]` in config,
// the dialog offers "JSON" first and "Acme projects" second.
export function openProject() {
  return useFiles(app).pick({ filters: [{ name: 'JSON', extensions: ['json'] }] })
}

Extensions are cleaned first: '.PNG', 'png' and ' png ' are the same, and '*' or '' is dropped. acceptFor() turns filters into the accept string the fallback uses:

import { acceptFor } from '@kitr/files'

acceptFor([
  { name: 'Images', extensions: ['.PNG', 'jpg', '*'] },
  { name: 'PDF', extensions: ['pdf'] },
])
// => '.png,.jpg,.pdf'
app/lib/orders/check-file.ts
import type { PickedFile } from '@kitr/files'

export function assertCsv(file: PickedFile) {
  if (!file.name.toLowerCase().endsWith('.csv')) throw new Error(`${file.name} is not a CSV file`)
}

Read a file's content

A PickedFile reads its content only when you ask:

Member Type Description
name string File name with extension, such as taxi.jpg.
size number Size in bytes.
type string The MIME type the platform reports, such as image/jpeg. Can be ''.
path string | undefined Absolute path, only on drivers that have one. Always undefined in a browser.
text() Promise<string> The content as UTF-8 text.
bytes() Promise<Uint8Array> The content as bytes.
blob() Promise<Blob> The content as a Blob. In a browser this is the original File.

Use text() for CSV, JSON and Markdown, bytes() when you parse a binary format yourself, and blob() to preview or send a file:

app/components/invoices/receipt-preview.tsx
import type { PickedFile } from '@kitr/files'
import { useEffect, useState } from 'react'

export function ReceiptPreview({ file }: { file: PickedFile }) {
  const [url, setUrl] = useState<string>()

  useEffect(() => {
    let objectUrl: string | undefined
    file.blob().then((blob) => {
      objectUrl = URL.createObjectURL(blob)
      setUrl(objectUrl)
    })
    return () => {
      if (objectUrl) URL.revokeObjectURL(objectUrl)
    }
  }, [file])

  if (!url) return null
  return file.type === 'application/pdf' ? (
    <iframe src={url} title={file.name} />
  ) : (
    <img src={url} alt={file.name} />
  )
}

To turn a browser File from elsewhere, such as a paste event, into a PickedFile, call fromFile(file).

Save text, bytes and blobs

save(data, options) accepts a string, Blob, File, Uint8Array or ArrayBuffer. name is required. In the download fallback, type sets the MIME type; otherwise it comes from the extension:

app/lib/invoices/export.ts
import { useFiles } from '@kitr/files'
import { app } from '#app/app'

// Text: the type is text/csv, from the extension.
export function exportInvoiceCsv() {
  return useFiles(app).save('id,total\nINV-1042,129', { name: 'invoices-2024-11.csv' })
}

// JSON for the app's own format, with the type set explicitly.
export function exportProject() {
  const project = { id: 'prj_7', name: 'Website relaunch', team: 'Design' }
  return useFiles(app).save(JSON.stringify(project, null, 2), {
    name: 'website-relaunch.acme',
    type: 'application/json',
    filters: [{ name: 'Acme projects', extensions: ['acme'] }],
  })
}

// Bytes from an API that renders PDFs.
export async function downloadInvoicePdf() {
  const response = await fetch('https://api.acme.com/invoices/INV-1042.pdf')
  return useFiles(app).save(await response.blob(), { name: 'INV-1042.pdf' })
}

mimeFor(name) is the lookup save() uses. It knows common extensions (txt, csv, json, png, pdf, zip and a few more) and returns application/octet-stream, or the fallback you pass, for the rest:

import { mimeFor, toBlob } from '@kitr/files'

mimeFor('INV-1042.PDF') // => 'application/pdf'
mimeFor('website-relaunch.acme') // => 'application/octet-stream'
mimeFor('website-relaunch.acme', 'application/json') // => 'application/json'

toBlob('id,total', 'text/csv').type // => 'text/csv'

toBlob(data, type) turns any FileData into a Blob.

Know when the person cancelled

Cancelling is not an error. open() resolves to [], and pick() and save() to null, so a cancel needs no try/catch:

app/lib/orders/import.ts
import { useFiles } from '@kitr/files'
import { app } from '#app/app'

export async function importOrdersFile() {
  const file = await useFiles(app).pick({ filters: [{ name: 'Spreadsheets', extensions: ['csv'] }] })
  if (file === null) return null // Ada closed the dialog. Leave the screen as it was.
  return file.text()
}
Cancel behaviour in the browser fallbacks

With the download fallback, save() resolves to name as soon as the download starts, because the browser doesn't say whether the person kept the file. Show "Download started", not "Saved".

With the file input fallback, a cancel resolves [] only in browsers that fire the input's cancel event. In older browsers the dialog never resolves, and busy stays true until the next dialog completes.

Show a busy state while a dialog is open

useFileDialogs() sets busy from the moment a call starts until it settles, including the time the dialog is on screen:

app/components/invoices/export-invoices.tsx
import { useFileDialogs } from '@kitr/files/react'

export function ExportInvoices({ csv }: { csv: string }) {
  const { save, busy } = useFileDialogs()
  return (
    <button type="button" disabled={busy} onClick={() => save(csv, { name: 'acme-invoices.csv' })}>
      {busy ? 'Exporting…' : 'Export invoices'}
    </button>
  )
}

Disabling the button stops a double click from opening two dialogs. useFiles() from @kitr/files/react returns the service itself, including reveal() and driver.

Accept dropped files

useDropFiles(onFiles) returns drag-and-drop handlers plus an over flag for styling the drop zone:

app/components/invoices/receipt-drop-zone.tsx
import type { PickedFile } from '@kitr/files'
import { useDropFiles } from '@kitr/files/react'

export function ReceiptDropZone({ onReceipts }: { onReceipts: (files: PickedFile[]) => void }) {
  const { over, ...handlers } = useDropFiles(onReceipts)
  return (
    <div {...handlers} className={over ? 'border-2 border-blue-500' : 'border-2 border-dashed'}>
      Drop receipts here
    </div>
  )
}

Use files outside React

Outside components, useFiles(app) from @kitr/files is a shortcut for app.container.use('files'):

app/lib/projects/project-files.ts
import { useFiles } from '@kitr/files'
import { app } from '#app/app'

export interface Project {
  id: string
  name: string
  team: string
}

export async function exportProject(project: Project) {
  const files = useFiles(app)
  return files.save(JSON.stringify(project, null, 2), { name: `${project.id}.acme` })
}

export async function importProject(): Promise<Project | null> {
  const file = await useFiles(app).pick()
  return file ? (JSON.parse(await file.text()) as Project) : null
}

files.driver.name tells you which driver is active: web, memory, or a platform driver's name.

Open a saved file in its app

reveal(path) opens a file with the system's default app. Only drivers that know real paths have it, so check first:

app/lib/invoices/open-invoice.ts
import { useFiles } from '@kitr/files'
import { app } from '#app/app'

export async function saveAndOpenInvoice() {
  const files = useFiles(app)
  const pdf = await (await fetch('/api/invoices/INV-1042.pdf')).blob()
  const where = await files.save(pdf, { name: 'INV-1042.pdf' })
  if (where && files.driver.reveal) await files.reveal(where)
}

Without reveal the call does nothing. In components, use useFiles(); useFileDialogs() doesn't return reveal.

Send a picked file to your server

For a small file and your own endpoint, a PickedFile's Blob goes straight into FormData:

app/lib/invoices/upload-receipt.ts
import type { PickedFile } from '@kitr/files'

export async function uploadReceipt(invoiceId: string, file: PickedFile) {
  const body = new FormData()
  body.append('receipt', await file.blob(), file.name)
  const response = await fetch(`/api/invoices/${invoiceId}/receipts`, { method: 'POST', body })
  if (!response.ok) throw new Error(`Upload failed with ${response.status}`)
  return (await response.json()) as { id: string; url: string }
}

For progress, size limits, retries or direct uploads to object storage, pass the blob to Uploads instead.

Where it runs

The service picks a driver once, on first use:

Where the app runs Driver Open Save path
Chrome, Edge and other Chromium browsers web File System Access picker Save picker, writes the chosen file no
Firefox, Safari web Hidden <input type="file"> Download link no
Desktop shell (Electron or Tauri) The one you register, else E_DRIVER_UNAVAILABLE; web with fallbackToWeb The shell's webview decides The shell's webview decides no
Mobile shell (Capacitor) capacitor The system document picker Cache file, then the share sheet yes
Electron electron: dialog in the main process, fs/promises Native open dialog Native save dialog yes
Tauri tauri: plugin-dialog and plugin-fs Native open dialog Native save dialog yes
Mobile shell (Capacitor) The one you register, else E_DRIVER_UNAVAILABLE; web with fallbackToWeb The system picker behind <input type="file"> Download link no
Server memory Resolves [] Records the call, resolves name no

Chrome and Edge

The web driver calls showOpenFilePicker and showSaveFilePicker, which write into the file the person chose. The API needs a secure context: https:// or http://localhost. See MDN for support.

Firefox and Safari

The web driver clicks a hidden <input type="file"> with accept set from your filters. To save, it clicks a hidden <a download>; the browser decides where the file goes.

Desktop

Desktop (Electron) and Tauri show the operating system's dialogs and give every picked file its path; see Use it in Electron and Tauri. Set fallbackToWeb: true only to use the webview's pickers instead.

Mobile

In the Mobile shell the service uses three optional peer dependencies, which kitr add files installs when the project has the mobile platform.

Plugin Used for
@capawesome/capacitor-file-picker open() and pick(): the system document picker. The filters become MIME types; an extension kitr cannot map to a MIME type offers every file.
@capacitor/filesystem save() writes the file; picked files read their content from their path.
@capacitor/share save() opens the share sheet; reveal(path) hands a path to it.

Mobile has no "save as" dialog. save() writes to the app's cache and opens the share sheet, where Ada chooses Save to Files, Drive or Mail. It resolves to the cached file:// URI, or null if she dismisses the sheet.

app/components/export-invoices.tsx
import { useFileDialogs } from '@kitr/files/react'

export function ExportInvoices({ csv }: { csv: string }) {
  const files = useFileDialogs()
  return (
    <button type="button" onClick={async () => console.log(await files.save(csv, { name: 'invoices.csv' }))}>
      Export invoices
    </button>
  )
}
// => file:///data/user/0/com.acme.app/cache/invoices.csv   (null if dismissed)

capacitorFileStore() reads, writes, deletes and lists files in the app's own directories with no dialog: data (default, private and backed up), documents, cache, library or external.

app/drafts.ts
import { capacitorFileStore } from '@kitr/files'

const drafts = capacitorFileStore({ directory: 'data' })

await drafts.write('drafts/invoice-1042.json', JSON.stringify({ total: 120 }))
// => 'file:///data/user/0/com.acme.app/files/drafts/invoice-1042.json'
await drafts.list('drafts') // => [{ name: 'invoice-1042.json', type: 'file', size: 13, modifiedAt: 1767000000000, uri: '...' }]
await drafts.readText('drafts/invoice-1042.json') // => '{"total":120}'
await drafts.exists('drafts/invoice-1042.json') // => true
await drafts.remove('drafts/invoice-1042.json')

A plugin that is not installed rejects with E_DRIVER_UNAVAILABLE and the package to install, never a silent fallback.

Use it in Electron and Tauri

In a desktop shell the service shows the operating system's dialogs and every PickedFile has its path. config/files.ts does not change. The window reaches only paths the person chose.

Electron

open() and save() use Electron's file dialogs, and the window can only read and write paths a dialog returned in this session. To pick a folder, import the driver:

app/invoices/folder.ts
import { createElectronFilesDriver } from '@kitr/files'

/** Let Grace pick the folder the month's invoices live in, then read each file. */
export async function readInvoiceFolder() {
  const folder = await createElectronFilesDriver().openDirectory({ title: 'Invoices' })
  if (!folder) return [] // cancelled
  return Promise.all((await folder.list()).map((file) => file.text()))
}

Requirements:

  • handleKitrIpc() in main.ts and exposeIpc() in the preload script. Without the kitrIpc bridge, creating the service throws E_DRIVER_UNAVAILABLE.
  • Files cross the process boundary as one Uint8Array, not a stream.

Tauri

Dialogs use @tauri-apps/plugin-dialog, content uses @tauri-apps/plugin-fs, and reveal(path) uses @tauri-apps/plugin-opener.

Requirements:

  • The three plugins in package.json; kitr add tauri and kitr add files install them. A missing one rejects with E_DRIVER_UNAVAILABLE naming the package.
  • The matching crates in src-tauri/Cargo.toml, their .plugin(...) calls in lib.rs, and the permissions dialog:default, fs:allow-read-file, fs:allow-write-file, fs:allow-stat, fs:allow-read-dir and opener:allow-open-path in capabilities/default.json. The Tauri scaffold writes all of them.

Errors

Anything other than a cancel reaches your code as thrown:

Error When What to do
E_DRIVER_UNAVAILABLE (KitrError, data.runtime, data.package) The app runs in Electron, Tauri or Capacitor and no files driver is registered. Register a driver with app.drivers.register(), or set fallbackToWeb: true in config/files.ts to run the web driver inside the webview.
AbortError The person cancelled the File System Access picker. Nothing: @kitr/files catches it and resolves [] or null.
SecurityError The picker was called without a user gesture, such as from a timer or after an await that took too long. Call open, pick or save directly from a click or key handler.
NotAllowedError The browser or the person denied write access to the chosen file. Show a message and offer to save again.
TypeError The picker rejected its options, such as a filter it can't read. Check your filters.
app/lib/invoices/export-safely.ts
import { useFiles } from '@kitr/files'
import { app } from '#app/app'

export async function exportInvoices(csv: string) {
  try {
    return await useFiles(app).save(csv, { name: 'acme-invoices.csv' })
  } catch (error) {
    if (error instanceof Error && error.name === 'SecurityError') {
      throw new Error('Click Export again to choose where to save.')
    }
    throw error
  }
}

Testing

Pass createMemoryFilesDriver(queue) as the driver in config. open() and pick() take files from queue, and save() records each call in driver.saved. An empty queue behaves like a cancel.

app/lib/orders/files.test.ts
import { createMemoryFilesDriver, fromFile, useFiles } from '@kitr/files'
import filesProvider from '@kitr/files/provider'
import { createTestApp } from '@kitr/testing'
import { describe, expect, it } from 'vitest'

describe('order files', () => {
  it('imports a picked CSV and exports invoices', async () => {
    const csv = new File(['id,customer,total\nord_1042,Ada,129'], 'acme-orders.csv', {
      type: 'text/csv',
    })
    const driver = createMemoryFilesDriver([fromFile(csv)])
    const app = await createTestApp({
      providers: [filesProvider],
      config: { files: { driver, filters: [{ name: 'Acme projects', extensions: ['acme'] }] } },
    })
    const files = useFiles(app)

    const picked = await files.pick({ filters: [{ name: 'Spreadsheets', extensions: ['csv'] }] })
    expect(picked?.name).toBe('acme-orders.csv')
    expect(await picked?.text()).toContain('ord_1042,Ada,129')

    expect(await files.pick()).toBeNull()

    expect(await files.save('id,total\nINV-1042,129', { name: 'acme-invoices.csv' })).toBe(
      'acme-invoices.csv',
    )
    expect(driver.saved).toEqual([{ name: 'acme-invoices.csv', data: 'id,total\nINV-1042,129' }])
  })
})

open({ multiple: true }) takes every queued file; otherwise each call takes one. Push more with driver.queue.push(...).

To test a component, render it with renderWithKitr from @kitr/testing/react in a DOM environment and read driver.saved:

app/components/orders/export-orders-button.test.tsx
// @vitest-environment happy-dom
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createMemoryFilesDriver } from '@kitr/files'
import filesProvider from '@kitr/files/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { expect, it } from 'vitest'
import { ExportOrdersButton } from './export-orders-button'

it('exports the orders as CSV', async () => {
  const driver = createMemoryFilesDriver()
  const app = await createTestApp({ providers: [filesProvider], config: { files: { driver } } })
  renderWithKitr(<ExportOrdersButton />, { app })

  fireEvent.click(screen.getByRole('button', { name: 'Export orders' }))

  await waitFor(() => expect(driver.saved).toHaveLength(1))
  expect(driver.saved[0]?.name).toBe('acme-orders.csv')
  expect(driver.saved[0]?.data).toBe('id,customer,total\nord_1042,Ada,129\nord_1043,Grace,80')
})

Extend

Write a driver when a platform has better dialogs than the browser. The interface is FilesDriver in the API reference:

Member Description
name Shown in devtools and on files.driver.name.
open(options) Show the open dialog with filters, multiple and title. Resolve to the picked files, or [] when cancelled.
save(data, options) Write data where the person chooses, starting from options.name. Resolve to the path or name, or null when cancelled.
reveal(path) Optional. Open a path with the system's default app.

options.filters already includes the config filters when the service calls your driver.

An Electron driver

The built-in Electron driver covers the common case; this one is written over your own IPC contract through Desktop's typed IPC. First add three commands to the contract:

platforms/app/ipc.ts
import type { FileFilter } from '@kitr/files'

export type Ipc = {
  'app.version': () => string
  'files.open': (options: { filters?: FileFilter[]; multiple?: boolean; title?: string }) => Array<{
    path: string
    bytes: Uint8Array
  }>
  'files.save': (data: string | Uint8Array, options: { name: string; filters?: FileFilter[]; title?: string }) => string | null
  'files.reveal': (path: string) => void
}

Then implement them in the main process:

platforms/app/main.ts
import { readFile, writeFile } from 'node:fs/promises'
import { app, dialog, ipcMain, shell } from 'electron'
import { handleIpc, handleKitrIpc } from '@kitr/desktop/main'
import type { Ipc } from './ipc'

handleIpc<Ipc>(ipcMain, {
  'app.version': () => app.getVersion(),
  'files.open': async (_event, options) => {
    const result = await dialog.showOpenDialog({
      title: options.title,
      filters: options.filters,
      properties: options.multiple ? ['openFile', 'multiSelections'] : ['openFile'],
    })
    if (result.canceled) return []
    return Promise.all(result.filePaths.map(async (path) => ({ path, bytes: await readFile(path) })))
  },
  'files.save': async (_event, data, options) => {
    const result = await dialog.showSaveDialog({
      title: options.title,
      defaultPath: options.name,
      filters: options.filters,
    })
    if (result.canceled || !result.filePath) return null
    await writeFile(result.filePath, data)
    return result.filePath
  },
  'files.reveal': async (_event, path) => {
    await shell.openPath(path)
  },
})

app.whenReady().then(() => {
  handleKitrIpc(ipcMain, app)
  // ... create the window
})

In the renderer, turn the IPC calls into a FilesDriver and register it under the files capability from a provider:

app/providers/electron-files.ts
import { defineProvider } from '@kitr/kitr/app'
import { createIpcClient } from '@kitr/desktop/client'
import { type FileData, type FilesDriver, mimeFor, type PickedFile, toBlob } from '@kitr/files'
import type { Ipc } from '../../platforms/app/ipc'

const ipc = createIpcClient<Ipc>()

function picked(path: string, bytes: Uint8Array): PickedFile {
  const name = path.split(/[\\/]/).pop() ?? path
  const type = mimeFor(name, '')
  return {
    name,
    path,
    size: bytes.byteLength,
    type,
    text: async () => new TextDecoder().decode(bytes),
    bytes: async () => bytes,
    blob: async () => toBlob(bytes, type),
  }
}

async function toSendable(data: FileData): Promise<string | Uint8Array> {
  if (typeof data === 'string' || data instanceof Uint8Array) return data
  if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer())
  return new Uint8Array(data)
}

export const electronFilesDriver: FilesDriver = {
  name: 'electron',
  async open(options) {
    const files = await ipc.invoke('files.open', options)
    return files.map((f) => picked(f.path, f.bytes))
  },
  async save(data, options) {
    return ipc.invoke('files.save', await toSendable(data), options)
  },
  reveal: (path) => ipc.invoke('files.reveal', path),
}

export default defineProvider({
  name: 'electron-files',
  register(app) {
    if (ipc.available) app.drivers.register('files', electronFilesDriver, { platform: 'desktop' })
  },
})

Add () => import('#app/providers/electron-files') to providers in kitrrc.ts; order doesn't matter. In a plain browser ipc.available is false, nothing is registered, and the web driver runs. The same shape works for any shell.

Devtools

With Devtools installed, open and save write to the Log panel under files. pick() doesn't log.

Next steps