DEV Community

Cover image for Real files and native sharing from a web app — File System Access & Web Share (FieldKit #5)
Oleksandr Trukhnii
Oleksandr Trukhnii

Posted on

Real files and native sharing from a web app — File System Access & Web Share (FieldKit #5)

This is part 5 of FieldKit, a series where I build one real Progressive Web App and use it to dig into what modern PWAs can actually do. FieldKit is a field-notes app — open source (on GitHub). It already works offline, installs, captures media, and geotags notes. Now we let data flow in and out: export/import as real files, and share notes with other apps.

Data that can't leave is data you don't own

A field journal is only trustworthy if you can get your data out — back it up, move it to another device, hand it to a colleague. And getting data in (restoring a backup, receiving a shared photo) is the other half. This part is about the two-way door:

  1. Export/Import real files with the File System Access API, with fallbacks that work everywhere.
  2. Share out a note via the Web Share API.
  3. Receive shares from other apps via a Web Share Target — handled entirely in the service worker, no server.

This is also the part where browser support is most uneven, so the fallbacks aren't optional. Let's be honest about that throughout.

File System Access: a real "Save As" dialog

Historically the web could only download files — dump them into the Downloads folder and hope. The File System Access API changes that: showSaveFilePicker() opens a genuine OS "Save As" dialog and hands back a writable handle you can stream to.

Here's FieldKit's export, modern path first:

if ("showSaveFilePicker" in window) {
  try {
    const handle = await window.showSaveFilePicker({
      suggestedName: filename,
      types: [
        { description: "FieldKit export", accept: { "application/json": [".json"] } },
      ],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
    return "saved";
  } catch (err) {
    if (err.name === "AbortError") return "cancelled"; // user closed the dialog
    // any other error: fall through to the download fallback
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details worth calling out. AbortError means the user cancelled the dialog — that's not a failure, don't toast an error. And showSaveFilePicker must be called from a user gesture (a click), like the install prompt in part 2.

The fallback that makes it universal

showSaveFilePicker is Chromium-only. Safari and Firefox don't have it. So the moment the modern path isn't available (or errors), we drop to the classic download trick:

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}
Enter fullscreen mode Exit fullscreen mode

Same result for the user — a file on disk — just without the nice "choose where" dialog. This "modern API with a boring reliable fallback" shape is the entire theme of building on the leading edge of the web: use the good thing where it exists, degrade cleanly where it doesn't.

Import mirrors it. showOpenFilePicker() on Chromium, an <input type="file"> everywhere else:

if ("showOpenFilePicker" in window) {
  const [handle] = await window.showOpenFilePicker({ /* types… */, multiple: false });
  file = await handle.getFile();
} else {
  file = await pickFileFallback(); // builds a hidden <input type="file">
}
const raw = JSON.parse(await file.text());
Enter fullscreen mode Exit fullscreen mode

A note on serialising media

Our notes can carry a photo or audio Blob, and JSON can't hold a Blob. So on export I base64 the media into a data URL, and on import I rebuild the Blob — which keeps the export a single, self-contained file:

media: e.media
  ? { type: e.media.type, dataUrl: await blobToDataURL(e.media.blob) }
  : null,
Enter fullscreen mode Exit fullscreen mode

It's not the most compact format (base64 adds ~33%), but for a portable backup that "just works" as one file, it's the right trade-off. Re-importing is safe too: the app upserts by id, so importing the same file twice doesn't create duplicates.

Web Share: hand a note to any app

Getting data out to other apps is the Web Share APInavigator.share(). It triggers the native share sheet, so your web app can send text and files to Messages, Mail, WhatsApp, anything registered on the device:

export async function shareEntry(entry) {
  if (!navigator.share) {
    throw new Error("Sharing isn't supported in this browser.");
  }
  const data = { title: "FieldKit note", text: entry.text || "A field note" };

  // Attach the photo as a file only if the platform can share files.
  if (entry.media?.type === "image" && navigator.canShare) {
    const file = new File([entry.media.blob], "fieldkit-photo.jpg", {
      type: entry.media.blob.type || "image/jpeg",
    });
    if (navigator.canShare({ files: [file] })) data.files = [file];
  }

  try {
    await navigator.share(data);
  } catch (err) {
    if (err.name !== "AbortError") throw err; // user cancelled the sheet
  }
}
Enter fullscreen mode Exit fullscreen mode

The key defensive move is navigator.canShare({ files }). Sharing text is widely supported; sharing files is not. Feature-detect files specifically before attaching them, or you'll throw on a browser that shares text fine but chokes on a file. And, again, navigator.share() must be user-gesture triggered.

Native share sheet

Web Share Target: receive shares, with no server

The flashiest trick here is the other direction — making FieldKit a destination in the OS share sheet, so you can share a photo from your gallery straight into a new field note. That's a Web Share Target, declared in the manifest:

"share_target": {
  "action": "/share-target",
  "method": "POST",
  "enctype": "multipart/form-data",
  "params": {
    "title": "title",
    "text": "text",
    "files": [{ "name": "media", "accept": ["image/*", "audio/*"] }]
  }
}
Enter fullscreen mode Exit fullscreen mode

When the user shares into an installed FieldKit, the OS sends a POST to /share-target. There's no server — but there doesn't need to be, because the service worker can intercept that POST, read the form data, save an entry, and redirect back into the app:

if (request.method === "POST" && url.pathname === "/share-target") {
  event.respondWith(handleShareTarget(request));
  return;
}
Enter fullscreen mode Exit fullscreen mode
async function handleShareTarget(request) {
  const form = await request.formData();
  const text = [form.get("title"), form.get("text")].filter(Boolean).join("");
  const file = form.get("media"); // a File, per the manifest params

  let media = null;
  if (file && file.size) {
    const type = file.type.startsWith("audio/") ? "audio" : "image";
    media = { type, blob: file }; // a File is a Blob — stores fine in IndexedDB
  }

  await addSharedEntry({ id: crypto.randomUUID(), text, media, /* … */ });

  // 303 so the browser follows with a GET of the app, not a POST replay.
  return Response.redirect("/?shared=1", 303);
}
Enter fullscreen mode Exit fullscreen mode

The service worker writes straight to the same IndexedDB the page uses (mirroring the store schema from part 1), so when the redirect lands, the shared note is simply there in the feed. The whole round-trip — from the Android share sheet into a stored note — happens with zero backend. That still feels a little magical to me.

Two caveats worth being upfront about: Web Share Target only works on an installed PWA (another reason part 2 matters), and it's Chromium/Android territory — iOS doesn't support it.

Honest support picture

  • File System Access API (showSaveFilePicker/showOpenFilePicker): Chromium only (desktop and Android). Not in Safari or Firefox — hence the download / <input type=file> fallbacks, which are universal.
  • Web Share API (navigator.share): broad, including Safari (desktop and iOS) and Android Chromium. File sharing is narrower than text sharing — always navigator.canShare({ files }) first. Not in desktop Firefox.
  • Web Share Target: installed PWAs on Chromium/Android only; no iOS/Safari, no Firefox.
  • All of these require a secure context and a user gesture for the entry points.

The pattern to internalise: the capability (save a file, share a note) is achievable everywhere; the premium API isn't. Build the fallback first, layer the nice API on top. Check caniuse: File System Access and Web Share before promising specifics.

How this compares to Electron

Files and sharing are where Electron's desktop-native roots show most:

  • Electron has the full Node fs module and Electron's dialog.showSaveDialog — unrestricted read/write anywhere the OS permits, no browser sandbox, no per-call permission. It's strictly more powerful for file work. The flip side: it's desktop-only, and you own the file dialogs and OS integration. (Sharing to other apps, ironically, is harder in Electron — there's no built-in share sheet; you reach for OS-specific APIs or shell calls.)
  • The PWA trades raw file power for reach and safety: the File System Access API is a sandboxed, permissioned subset of what fs gives Electron — but the same code runs on Android, and where the API is missing you fall back gracefully. And the PWA gets the native share sheet for free via navigator.share, on mobile especially, which is exactly where sharing matters most.

Rule of thumb: for heavy local file manipulation on desktop, Electron's fs wins outright. For "let users back up, restore, and share from a single app that runs on their phone and their laptop," the PWA's sandboxed files + native share sheet is the better-fitting tool — provided you write the fallbacks.

Try it

Serve FieldKit over localhost. Hit Export (a real Save dialog on Chrome, a download on Safari/Firefox), then Import to restore. Tap Share on any note to open the native share sheet. On an installed FieldKit on Android, share a photo from your gallery into FieldKit and watch a new note appear.

git clone https://github.com/JohnJunior/FieldKit.git
cd FieldKit
npx serve .
Enter fullscreen mode Exit fullscreen mode

Next up (FieldKit #6): push notifications — the Push API, subscriptions and VAPID, handling pushes in the service worker, and why iOS only delivers them to an installed PWA.

Top comments (0)