Skip to content

File Handling API

The File Handling API lets an installed PWA register with the operating system as an application for specific file types, so it appears in the file manager's Open with menu, can be made the default app for a type, and receives the files the user opens as FileSystemFileHandle objects. It has two halves: the declarative file_handlers manifest member, which the browser turns into OS file associations when the app is installed, and the imperative window.launchQueue.setConsumer() call through which the page receives each launch. It is available in Chromium-based browsers on desktop (Windows, macOS, Linux and ChromeOS) since Chrome 102, and not in Safari, Firefox or Chrome for Android.

Key takeaways

  • Nothing happens until the app is installed. Chromium registers the handlers with the OS at install time, re-registers them when a manifest update changes file_handlers, and removes them on uninstall.
  • Every accept entry needs both a MIME type and extensions: Windows only looks at extensions, Linux registers both. Chromium caps the total at 300 extensions, matches file extensions case-insensitively, and ignores the icons member.
  • Files arrive in LaunchParams.files on the page, not in the service worker. Chromium grants read access only; writing back triggers a permission prompt that needs a user gesture.
  • launch_type decides between one launch for all selected files ("single-client", default) and one launch per file ("multiple-clients"). Windows always launches once per file. Combine with launch_handler "focus-existing" to keep everything in one window.
  • The first file launch shows "Open and edit file in this web app?" with a "Remember my choice" checkbox. Refusing permanently removes the app from the OS's Open with lists; users can turn it back on in chrome://app-settings.
  • Since Chrome 146, reloading the page no longer replays the last launch, and targetURL is filled in for file launches. Persist handles in IndexedDB if the app must survive a reload.

How file handling works end to end

The API spans the manifest, the browser's install machinery, the operating system and your page. It helps to see the whole chain before looking at each part.

sequenceDiagram
    participant Dev as Manifest
    participant Browser
    participant OS
    participant User
    participant Page
    Browser->>Dev: install reads file_handlers
    Browser->>OS: register file types for the app
    User->>OS: open photo.png with the app
    OS->>Browser: launch app with the file path
    Browser->>Browser: match the file to a handler
    Browser->>User: "Open and edit photo.png in this web app?"
    User-->>Browser: Open
    Browser->>Page: open or reuse a window per launch_handler
    Browser->>Page: enqueue LaunchParams with targetURL and files
    Page->>Page: launchQueue consumer reads the handles
  1. Declare the file types in file_handlers, each mapped to an action URL inside the app's scope.
  2. Install. Chromium writes OS-level associations: registry entries on Windows, document types in the app shim's Info.plist on macOS, a MIME database entry and .desktop metadata on Linux, and an Open with entry in the ChromeOS Files app.
  3. Open. The user double-clicks a file (if the app is the default), or picks the app from Open with. The OS starts the browser with the app ID and the file paths.
  4. Match and confirm. Chromium assigns each file to the first handler whose extensions match, asks the user for permission the first time, and groups the files into launches according to launch_type.
  5. Deliver. Each launch opens or reuses a window according to launch_handler, navigates it to action if needed, and enqueues a LaunchParams object in that document's window.launchQueue.
  6. Consume. Your consumer receives the handles and reads them with the File System Access API.

Browser and platform support

Platform File Handling Notes
Chrome and Edge on Windows, macOS, Linux ✅ 102 Installed apps only
ChromeOS ✅ 102 Open with in the Files app
Opera desktop ✅ 88 Chromium 102
Chrome for Android (WebAPK) ❌ See Android and Trusted Web Activities
Safari (macOS, iOS, iPadOS) ❌ Not implemented
Firefox ❌ Mozilla's position is defer
Chrome version Change relevant to file handling
Before 102 Origin trials only; the API shape changed before it shipped
102 file_handlers and LaunchParams.files ship on desktop
110 launch_handler and LaunchParams.targetURL ship
122 Persistent File System Access permissions; installed apps keep file grants across sessions
146 Reload no longer re-queues the last LaunchParams; targetURL populated for file launches

Support data as of September 2026. See MDN's file_handlers compatibility data and caniuse for file_handlers and LaunchParams.files for live data. Mozilla lists the proposal as defer ("Not far enough along to properly evaluate"). The file_handlers member is specified in the WICG Manifest Incubations draft, and LaunchQueue/LaunchParams in the WICG Web App Launch Handler draft.

Feature detection

Chrome's documented check tests for the launch queue and for the files attribute:

src/file-handling-support.js
export const fileHandlingSupported =
  "launchQueue" in window && "files" in LaunchParams.prototype;

// Detects the API, not whether THIS installation registered handlers.
// File launches can only happen in an installed app window:
export const runningInstalled =
  matchMedia("(display-mode: standalone)").matches ||
  matchMedia("(display-mode: window-controls-overlay)").matches ||
  matchMedia("(display-mode: minimal-ui)").matches;

A true result means the browser can deliver files to a page, not that the OS knows about your app. The app may be running in a tab, the user may have disabled file handling for it, or the platform (Android) may expose the launch queue without registering file types. Keep an in-app Open button and drag and drop regardless; see Detecting Installed Apps for reliable installation detection.

Declaring file_handlers in the manifest

file_handlers is an array of handler objects. Each handler maps a set of file types to a URL in your app.

manifest.webmanifest
{
  "file_handlers": [
    {
      "action": "/open/csv",
      "name": "CSV table",
      "accept": {
        "text/csv": [".csv"],
        "text/tab-separated-values": [".tsv", ".tab"]
      }
    },
    {
      "action": "/open/project",
      "name": "Grafr project",
      "accept": {
        "application/vnd.grafr.project+json": [".grafr"]
      },
      "launch_type": "multiple-clients"
    }
  ]
}
Member Type Required Default Meaning
action URL string Yes none Page that handles the files. Resolved against the manifest URL and must be within the app's scope. It becomes LaunchParams.targetURL.
accept Object: MIME type to extension or list of extensions Yes none The file types this handler opens.
name String No OS default Human-readable name of the file type. Passed to the OS; typically only visible where the OS shows the type name for files the app is the default for.
icons Array of image resources No App icon Document icons for the file type. Specified, but not implemented: Chromium's manifest parser does not read it.
launch_type "single-client" or "multiple-clients" No "single-client" One launch for all matching files, or one launch per file.

The spec allows user agents to ignore name and icons for privacy and security reasons, because the user has no way to review them before they appear in OS surfaces. Chromium's "File Handling Icons" feature has been listed as in development on chromestatus for years with no shipping milestone.

Processing rules: the spec and Chromium

The spec processes each handler independently and skips invalid ones rather than rejecting the manifest. A handler is dropped when:

  • action is missing, not a string, fails to parse, or resolves outside scope;
  • accept is missing, not an object, or empty after its entries are filtered.

Each accept entry is filtered on its own. Per the spec, an entry is skipped when the key is not a parseable MIME type with an IANA top-level type (text, image, audio, video, application, font, model and so on), when the value is not a non-empty list, or when an extension does not start with . or is longer than 16 characters. * is allowed as the subtype ("image/*"), but the extensions still decide which files match.

Chromium's parser differs in ways that matter when you test in Chrome and ship to the spec:

Case Spec Chromium
Extensions given as one string: "text/csv": ".csv" Entry skipped (list required) Accepted
Extension "." alone, or without a leading dot Skipped Skipped: "must start with a '.' and contain at least one extension character"
Extension longer than 16 characters Skipped Not checked
Extension containing control or format characters Not mentioned Skipped
Total extensions across all handlers "MAY truncate" Hard limit of 300; the rest are dropped with "too many total file extensions"
launch_type casing or array form Exact string Case-insensitive; an array is accepted and the first recognized value wins
icons Processed as image resources Ignored
x-scheme-handler/* MIME types Not mentioned Excluded from OS registration (on Linux they would register URL protocol handlers)

Every skipped entry produces a warning in DevTools under Application > Manifest, and nowhere else. Check that pane after each manifest change.

How each operating system uses MIME types and extensions

The spec requires both halves of every accept entry because operating systems disagree on how file types are identified.

OS What Chromium registers Where
Windows Extensions only (MIME types are ignored). One ProgId per handler, so each handler's name can show separately, all pointing to an app-specific launcher executable. Current user's registry hive
macOS CFBundleDocumentTypes entries with both CFBundleTypeExtensions and CFBundleTypeMIMETypes Info.plist of the app shim bundle in ~/Applications/Chrome Apps.localized/
Linux A shared-mime-info XML file installed with xdg-mime install --mode user, the MIME types in the app's .desktop entry, then update-desktop-database so Nautilus and Nemo notice $XDG_DATA_HOME (usually ~/.local/share)
ChromeOS The app is offered in the Files app's Open with menu Browser-managed

On Linux the MIME definition file is named after the browser executable, the app ID and the profile directory, for example chrome-<app-id>-Default.xml. If a file manager does not list your app, a stale MIME cache is the usual cause; Chromium runs update-desktop-database for exactly that reason, but ignores failures of that step.

Choosing types responsibly

  • Prefer your own extension and a vendor MIME type (application/vnd.example.diagram+json with .exdiagram) for your own formats. They cannot collide with other apps, and the OS will usually make your app the handler with no fuss.
  • Register common types only if you are a real editor for them. Declaring .json, .txt or .png puts your app into every Open with menu for those types. It does not steal the default (the OS keeps the user's current default app), but it adds noise, and users who pick it expect a full editing experience.
  • Declare compound extensions explicitly. Chromium matches a launched file by its extension as computed by Chromium's path library, which treats common compression suffixes as part of a double extension: archive.tar.gz has the extension .tar.gz, and data.json.gz has .json.gz. A handler that only lists .gz does not match those files, even though the OS offers your app for them. List .tar.gz (or .json.gz) alongside .gz.
  • Files without an extension never match. Chromium skips files whose extension is empty, so you cannot handle Makefile or LICENSE through file handling.

launch_type: one launch or one per file

When the user opens several files at once, the browser has to decide how many launches to create. The spec's execute a file handler launch algorithm, which Chromium follows closely, works like this:

  1. For each opened file, walk the handlers in manifest order and assign the file to the first handler with a matching extension. Later handlers never see it, so put specific handlers before generic ones.
  2. Group the files by handler. Files for different handlers are never combined into one launch.
  3. For a "single-client" handler, create one launch whose files contains all of that handler's files. For "multiple-clients", create one launch per file.
  4. Launch each one through the normal app launch path, which applies launch_handler.
Selected files Handlers Launches
a.csv, b.csv, c.csv .csv handler, "single-client" 1 launch, 3 files
a.csv, b.csv, c.csv .csv handler, "multiple-clients" 3 launches, 1 file each
a.csv, diagram.grafr .csv handler and .grafr handler At least 2 launches (never mixed)
Any number of files, on Windows Any 1 launch per file

The Windows row is a platform limitation called out in the spec: Windows never starts an application with several files at once, it starts it once per file. launch_type therefore has no effect on Windows, and an app that must show multi-file selections in one window on every OS needs launch_handler (next section) and a consumer that merges launches.

Choose "multiple-clients" for apps that show exactly one document per window (a text editor that opens each file in its own window) and "single-client" for apps that can present a set (an image viewer's filmstrip, a diff tool comparing two files).

Receiving files: window.launchQueue and LaunchParams

Web App Launch Handler (WICG)
[Exposed=Window] interface LaunchParams {
  readonly attribute DOMString? targetURL;
  readonly attribute FrozenArray<FileSystemHandle> files;
};

callback LaunchConsumer = any (LaunchParams params);

partial interface Window {
  readonly attribute LaunchQueue launchQueue;
};

[Exposed=Window] interface LaunchQueue {
  undefined setConsumer(LaunchConsumer consumer);
};

The queue exists to avoid a race. Launch data arrives from the browser process at an unpredictable moment relative to your scripts: often before your module has finished loading, sometimes later. An event would be lost if it fired before the listener was attached, so the browser buffers every LaunchParams in the document's queue until a consumer is set, then calls the consumer once per buffered launch, in order. After that, new launches (for example, files opened into an existing window with focus-existing) call the consumer directly.

The details that matter:

  • Set the consumer early. Call setConsumer() at the top level of your entry module, before any await. Launches are not lost if you are late, but the user stares at an empty window until you consume them.
  • setConsumer() replaces the previous consumer. Queued and future launches go to the most recently set consumer. Set it once and dispatch inside it, especially if the app also handles protocol launches or captured links.
  • The consumer's return value is ignored. An async consumer that rejects produces an unhandled rejection and nothing else. Catch errors inside it and show them to the user.
  • Non-file launches arrive too. Chromium enqueues LaunchParams for launches into app windows generally (icon, shortcut, captured link, protocol), with an empty files array. Check files.length before assuming a file launch.
  • targetURL is the handler's action for file launches. Before Chrome 146 it was null when a file launch was delivered to an existing window; from 146 it is always set. Use it to tell handlers apart when several share a page.
  • Handles can in theory be directories. files is typed as FileSystemHandle, and Chromium's launch plumbing can deliver a directory entry on some platforms. Check kind.
  • The page, not the service worker, receives files. launchQueue is exposed on Window only. Post the handles to a worker if you need to parse there; handles are structured-cloneable.

Permissions on launched files

The Web App Launch Handler draft says that every handle in files must report "granted" for "readwrite". Chromium deliberately does not do that: its launch code creates file entries with read access only by default ("files sent through the launch queue will only have read access"). The first time you write, createWritable() or requestPermission({ mode: "readwrite" }) shows Chrome's write prompt, which needs transient user activation, so it must happen in a click or key handler such as your Save command, never in the launch consumer itself.

On an installed desktop app, persistent File System Access permissions (Chrome 122) keep those grants across sessions once the user allows them, and the handles can be stored in IndexedDB like any other; the details are on File System Access.

Reloads no longer replay launches (Chrome 146)

For years Chromium re-sent the last LaunchParams to a newly set consumer after a reload. It was never specified, and it was a stopgap from before handles could be stored in IndexedDB. It caused real bugs ("overwrite file?" prompts on every refresh), and apps had to write code to detect reloads and ignore the duplicate. Chrome 146 stopped re-queueing on reload, so a reload is now treated as a plain navigation.

Two consequences for your code:

  1. Delete any "ignore the launch if this is a reload" logic; it is dead code on current Chrome.
  2. If users expect a reload (or a crash restore) to bring back the open file, persist the handles when they arrive and restore them yourself, as the example at the end of this page does.

A dispatching consumer

src/launch.js
// One consumer for every kind of launch into this app window.
// Call initLaunchHandling() at the top level of the entry module.
export function initLaunchHandling({ onFiles, onUrl }) {
  if (!("launchQueue" in window)) return false;

  window.launchQueue.setConsumer(async (params) => {
    try {
      const handles = [...(params.files ?? [])];
      if (handles.length > 0) {
        const files = handles.filter((h) => h.kind === "file");
        const target = params.targetURL ? new URL(params.targetURL) : null;
        await onFiles(files, target);
        return;
      }
      if (params.targetURL) {
        await onUrl(new URL(params.targetURL));
      }
    } catch (err) {
      // The return value of a consumer is ignored: report errors here.
      console.error("Launch handling failed", err);
      document.dispatchEvent(new CustomEvent("launch-error", { detail: err }));
    }
  });
  return true;
}

Combining file handling with launch_handler

launch_handler.client_mode decides which window a launch goes to. File launches follow it like every other launch, so it is the tool for "open the file in the window I already have".

client_mode File launch while an app window is open Effect on your page
"auto" (default) Chrome on desktop behaves like "navigate-new" A new window per launch
"navigate-new" A new window loads action Fresh document per launch; the consumer runs once in it
"navigate-existing" The most recently focused window navigates to action The old document is unloaded (unsaved state is lost unless you guard it); the new document's consumer receives the files
"focus-existing" The most recently focused window is focused, not navigated The existing document's consumer runs again with the new files

With "focus-existing", the existing window must currently show a URL within the app's scope; otherwise the spec requires the browser to navigate instead, so that LaunchParams never leak into a document outside the scope. If no window is open, every mode creates a new one.

client_mode also accepts an array, and the first value the browser supports is used, so you can adopt future modes without breaking older browsers. The full launch-handling model, including protocol handlers and link capturing, is on Protocol Handlers & Launch Handling, and the member itself is documented in Advanced & Integration Members.

Useful combinations:

  • Single-window viewer or editor: "launch_type": "single-client" plus "client_mode": "focus-existing". Files opened later are added to the open window; on Windows, each file arrives as its own consumer call in the same window.
  • One document per window: "launch_type": "multiple-clients" plus "client_mode": "navigate-new" (or "auto"). Each file gets a fresh window and a fresh document.
  • Avoid "navigate-existing" for editors. It replaces the page, so any unsaved work in the current document is gone unless a beforeunload guard stops the navigation.

OS registration, the permission dialog and user control

Install, update and uninstall

Chromium registers the handlers when the app is installed with OS integration, and keeps them in sync over the app's lifetime:

  • A manifest update that changes file_handlers is picked up by the normal manifest update process (see App Identity & Updates) and re-registered with the OS. Chrome's documentation notes that the user's file handling permission is reset when the file_handlers section changes.
  • Uninstalling removes the associations (and on Windows the stored ProgIds).
  • Changing the manifest id creates a different app with its own registrations, so settle the id before you ship file handlers.

Installing never changes the OS default for a type. The app is added to Open with; the user can then choose it as the default (on macOS through Get Info > Open with > Change All, on Windows through Open with > Choose another app and "Always use this app", or in the OS default-apps settings). The spec and Chrome's documentation note one exception: if no application at all handles a type, some operating systems may silently make the newly registered app the default, which is one reason Chromium asks the user before the first launch.

The launch dialog

The first time the app is launched with files, Chromium shows a dialog before the app window opens:

  • The question is "Open and edit todo.txt in this web app?" for one file, or "Open and edit N files in this web app?" for several (listing up to a dozen file names).
  • A checkbox reads "Remember my choice for this file type: TXT" (or "for these file types:" with the list of all types the app handles).
  • The buttons are Open and Don't open.

The outcomes:

User action Result
Open, checkbox unchecked This launch proceeds; the dialog appears again next time
Open, checkbox checked This and future launches proceed without asking
Don't open, checkbox unchecked No launch; the dialog appears again next time
Don't open, checkbox checked No launch, and Chromium removes the app's file associations from the OS, so it disappears from Open with
Dialog closed without a button Treated as Don't open without remembering

The Don't open plus "Remember" row surprises developers testing their own app: one careless click and the app no longer shows up anywhere. The fix is in the app's settings page. Chrome's documentation also notes that a user who ignores the prompt three times triggers Chromium's permission embargo, after which the permission is blocked, and that the stored choice survives app restarts but is reset when a manifest update changes file_handlers.

chrome://app-settings

Each installed web app has a settings page at chrome://app-settings/<app-id>; the app ID is listed in chrome://web-app-internals. For file handling it shows:

  • A toggle labeled "Include this app as an option when opening files", which re-registers or unregisters the associations. It reflects the "Remember my choice" decision above.
  • The list of supported file types ("Supported file types: TXT, CSV, …").
  • On Windows, a link to the Windows default-apps settings; on macOS, Linux and ChromeOS, a link explaining how to set default apps.

On ChromeOS, administrators can also make managed web apps the default handler for extensions through policy, which bypasses the user approval.

Security considerations

The capability a file handler adds over the File System Access API is that the OS, not a picker inside your page, hands the app a file. Chrome treats the user's explicit choice to open a file in an installed app as a signal of trust, backed by the launch dialog above. On your side:

  • Treat opened files as untrusted input. A .svg, .html or .md file opened from the Downloads folder may be hostile. Render it through <img>, a sandboxed iframe or a sanitizer, never innerHTML.
  • Validate content, not the extension. The OS matched on the extension; check magic bytes or let a real decoder (createImageBitmap(), img.decode()) reject garbage.
  • Do not write without asking. Launched handles are read-only in Chromium for a reason; write only in response to an explicit save action.

Android and Trusted Web Activities

Chrome for Android does not register file_handlers for WebAPK-installed PWAs, and MDN's compatibility data lists no Android support. The only Android route is a Trusted Web Activity, where the Android app shell, not the browser, declares the file types:

  • androidx.browser 1.9.0 (stable July 30, 2025; the feature arrived in 1.9.0-alpha02 in April 2025) lets TrustedWebActivityIntent carry the URIs of files opened through the app's intent filters and grant the browser read-write permission on them, alongside Launch Handler client modes.
  • Bubblewrap added file handling support in version 1.22.6 (May 2025): it reads file_handlers from the web manifest, keeps each handler's action and MIME types, and generates the corresponding intent filters. Android matches by MIME type, so extensions are not used there.

This is newer, less documented plumbing than the desktop implementation; verify the end-to-end flow on the Chrome versions your users run before relying on it. The installation model itself is covered on Android.

Testing file handling

File handling only runs in an installed app with OS integration, so testing is mostly manual. A repeatable routine:

  1. Serve over HTTPS or http://localhost (a secure context) and install the app from Chrome's address bar or menu. See Installability Criteria if the install option does not appear.
  2. Check what Chromium registered. chrome://web-app-internals lists every installed app as stored by the browser, including parsed file handlers, their launch_type, the launch_handler mode and the OS integration state. If a handler is missing there, look for a parser warning in Application > Manifest in DevTools.
  3. Check the app settings. chrome://app-settings/<app-id> must show the file types and the enabled "Include this app as an option when opening files" toggle.
  4. Open files through the OS (below), once with a single file and once with a multi-file selection, with and without an app window already open.
  5. Test the dialog paths: Open without "Remember", Open with "Remember", and Don't open. Re-enable the toggle afterwards.
  6. Test a manifest update: change file_handlers, let the update apply (or reinstall), and confirm the new types and the reset permission.
  7. Open DevTools inside the app window (Ctrl+Shift+I or Cmd+Option+I) to watch the consumer's logs; the window opened by a launch has its own DevTools.
  • In File Explorer, right-click a file, choose Open with, then Choose another app if the PWA is not listed yet.
  • Select several files and press Enter to see the one-launch-per-file behavior.
  • Default apps: Settings > Apps > Default apps, then search for the extension.
  • In Finder, right-click a file, Open With, then the app.
  • Make it the default: Get Info (Cmd+I), Open with, choose the app, Change All….
  • From Terminal: open -a "App Name" ~/Desktop/sample.png. The app shim lives in ~/Applications/Chrome Apps.localized/.
terminal
# Which MIME type does the system assign to the file?
xdg-mime query filetype ~/Pictures/sample.png

# Which .desktop entry is the default for that type?
xdg-mime query default image/png

# Open the file with the default handler
xdg-open ~/Pictures/sample.png

# Rebuild the desktop database if a file manager does not list the app
update-desktop-database ~/.local/share/applications
  • In the Files app, right-click a file, Open with, then the app.

For automated tests, keep the consumer logic in a function that accepts a plain { targetURL, files } object, and feed it handles from the origin private file system, which behave like launched handles for reading. What cannot be automated easily is the OS round trip itself, so keep a short manual checklist per platform in your release process (see Automated Testing).

Complete example: an image viewer

This viewer registers for common image types, shows every opened image in a filmstrip in one window, supports arrow-key navigation, rotates and saves images back to the original file (after asking for write access), accepts drag and drop and an Open button as fallbacks, and restores the previous session after a reload or restart. It uses no libraries.

viewer/manifest.webmanifest
{
  "id": "/viewer/",
  "name": "Pixel Viewer",
  "short_name": "Pixel",
  "start_url": "/viewer/",
  "scope": "/viewer/",
  "display": "standalone",
  "background_color": "#111111",
  "theme_color": "#111111",
  "icons": [
    { "src": "/viewer/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/viewer/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/viewer/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "file_handlers": [
    {
      "action": "/viewer/",
      "name": "Image",
      "accept": {
        "image/png": [".png"],
        "image/jpeg": [".jpg", ".jpeg", ".jfif"],
        "image/webp": [".webp"],
        "image/gif": [".gif"],
        "image/avif": [".avif"],
        "image/svg+xml": [".svg"]
      },
      "launch_type": "single-client"
    }
  ],
  "launch_handler": { "client_mode": "focus-existing" }
}

action equals start_url, so a file launch into a new window loads the normal app shell, and focus-existing sends later launches into the open window instead of spawning more.

viewer/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Pixel Viewer</title>
  <link rel="manifest" href="/viewer/manifest.webmanifest">
  <link rel="stylesheet" href="/viewer/viewer.css">
  <script type="module" src="/viewer/viewer.js"></script>
</head>
<body>
  <header>
    <button id="open" type="button">Open…</button>
    <button id="restore" type="button" hidden>Restore previous images</button>
    <span id="status" role="status" aria-live="polite"></span>
  </header>
  <main id="stage">
    <figure>
      <img id="image" alt="" hidden>
      <figcaption id="caption">Open or drop images, or use "Open with" in your file manager.</figcaption>
    </figure>
  </main>
  <footer>
    <button id="prev" type="button" aria-label="Previous image">◀</button>
    <button id="rotate" type="button">Rotate</button>
    <button id="save" type="button">Save rotation</button>
    <button id="next" type="button" aria-label="Next image">▶</button>
    <nav id="strip" aria-label="Opened images"></nav>
  </footer>
</body>
</html>
viewer/session-store.js
// Persists the handles of the current viewing session in IndexedDB so the
// viewer can restore them after a reload (Chrome 146+ no longer replays
// the launch on reload) or an app restart.
const DB_NAME = "pixel-viewer";
const STORE = "session";
const KEY = "current";

function openDb() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => request.result.createObjectStore(STORE);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function run(mode, operation) {
  const db = await openDb();
  try {
    return await new Promise((resolve, reject) => {
      const tx = db.transaction(STORE, mode);
      const request = operation(tx.objectStore(STORE));
      tx.oncomplete = () => resolve(request.result);
      tx.onerror = () => reject(tx.error);
      tx.onabort = () => reject(tx.error);
    });
  } finally {
    db.close();
  }
}

export function saveSession(handles) {
  return run("readwrite", (store) => store.put(handles, KEY));
}

export async function loadSession() {
  return (await run("readonly", (store) => store.get(KEY))) ?? [];
}
viewer/viewer.js
import { saveSession, loadSession } from "./session-store.js";

const RASTER_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
const IMAGE_EXTENSIONS = /\.(png|jpe?g|jfif|webp|gif|avif|svg)$/i;
const MAX_ITEMS = 300; // Bounds memory: each item holds an object URL.

const $ = (selector) => document.querySelector(selector);
const img = $("#image");
const caption = $("#caption");
const strip = $("#strip");
const statusEl = $("#status");

// items: { handle: FileSystemFileHandle | null, file: File, url: string }
const items = [];
let index = -1;
let rotation = 0; // Pending rotation of the current image, in degrees.

const setStatus = (text) => {
  statusEl.textContent = text;
};

function report(err) {
  if (err?.name === "AbortError") return; // Cancelled picker or prompt.
  console.error(err);
  setStatus(err?.message ?? String(err));
}

// ---------------------------------------------------------------------------
// Launches: set the consumer first, before anything is awaited.
// ---------------------------------------------------------------------------
if ("launchQueue" in window && "files" in LaunchParams.prototype) {
  window.launchQueue.setConsumer((params) => {
    const handles = [...params.files].filter((h) => h.kind === "file");
    if (handles.length === 0) return; // Icon or shortcut launch.
    // With focus-existing this also runs for files opened later into this
    // window; on Windows it runs once per file.
    addHandles(handles).catch(report);
  });
}

// ---------------------------------------------------------------------------
// Adding images
// ---------------------------------------------------------------------------
async function addHandles(handles) {
  const entries = [];
  for (const handle of handles) {
    try {
      entries.push({ handle, file: await handle.getFile() });
    } catch (err) {
      // NotFoundError (file moved) or NotAllowedError (access revoked).
      setStatus(`Skipped ${handle.name}: ${err.name}`);
    }
  }
  await addEntries(entries);
}

async function addEntries(entries) {
  let firstNew = -1;
  for (const { handle, file } of entries) {
    if (items.length >= MAX_ITEMS) {
      setStatus(`Showing the first ${MAX_ITEMS} images only`);
      break;
    }
    if (!file.type.startsWith("image/") && !IMAGE_EXTENSIONS.test(file.name)) {
      setStatus(`${file.name} is not an image`);
      continue;
    }
    // Reuse an existing entry for the same file instead of duplicating it.
    const existing = handle ? await findItem(handle) : -1;
    if (existing !== -1) {
      if (firstNew === -1) firstNew = existing;
      continue;
    }
    items.push({ handle, file, url: URL.createObjectURL(file) });
    if (firstNew === -1) firstNew = items.length - 1;
  }
  renderStrip();
  if (firstNew !== -1) await show(firstNew);
  persist();
}

async function findItem(handle) {
  for (let i = 0; i < items.length; i += 1) {
    if (items[i].handle && (await items[i].handle.isSameEntry(handle))) return i;
  }
  return -1;
}

function persist() {
  const handles = items.map((item) => item.handle).filter(Boolean);
  saveSession(handles).catch((err) => console.warn("Session not saved", err));
}

// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
async function show(i) {
  if (i < 0 || i >= items.length) return;
  index = i;
  rotation = 0;
  img.style.transform = "";
  const { file, url } = items[i];
  img.hidden = false;
  img.alt = file.name;
  // <img> never runs script, so SVG files are displayed safely.
  img.src = url;
  try {
    await img.decode();
    caption.textContent =
      `${file.name} · ${img.naturalWidth}×${img.naturalHeight} · ` +
      `${(file.size / 1024).toFixed(1)} KB · ` +
      `modified ${new Date(file.lastModified).toLocaleString()}`;
  } catch {
    caption.textContent = `${file.name} could not be decoded`;
  }
  for (const [n, thumb] of [...strip.children].entries()) {
    thumb.toggleAttribute("aria-current", n === index);
  }
}

function renderStrip() {
  strip.replaceChildren(
    ...items.map((item, i) => {
      const button = document.createElement("button");
      button.type = "button";
      button.title = item.file.name; // Attribute text, never HTML.
      const thumb = document.createElement("img");
      thumb.src = item.url;
      thumb.alt = item.file.name;
      thumb.loading = "lazy";
      thumb.decoding = "async";
      button.append(thumb);
      button.addEventListener("click", () => show(i).catch(report));
      return button;
    }),
  );
}

function rotate() {
  if (index === -1) return;
  rotation = (rotation + 90) % 360;
  img.style.transform = `rotate(${rotation}deg)`;
}

// ---------------------------------------------------------------------------
// Saving a rotation back to the original file
// ---------------------------------------------------------------------------
async function renderRotated(file, degrees) {
  // createImageBitmap applies EXIF orientation by default. Re-encoding drops
  // metadata such as EXIF and color profiles; tell users before overwriting.
  const bitmap = await createImageBitmap(file);
  const quarterTurn = degrees % 180 !== 0;
  const canvas = new OffscreenCanvas(
    quarterTurn ? bitmap.height : bitmap.width,
    quarterTurn ? bitmap.width : bitmap.height,
  );
  const ctx = canvas.getContext("2d");
  ctx.translate(canvas.width / 2, canvas.height / 2);
  ctx.rotate((degrees * Math.PI) / 180);
  ctx.drawImage(bitmap, -bitmap.width / 2, -bitmap.height / 2);
  bitmap.close();
  return canvas.convertToBlob({ type: file.type, quality: 0.92 });
}

async function saveRotation() {
  const item = items[index];
  if (!item || rotation === 0) return;
  if (!RASTER_TYPES.has(item.file.type)) {
    setStatus("Rotation can be saved for PNG, JPEG and WebP files only");
    return;
  }
  const { handle } = item;
  if (!handle) {
    setStatus("This image was opened as a copy and cannot be saved in place");
    return;
  }
  // Launched and dropped handles are read-only. Ask for write access while
  // this click's user activation is still valid, before any slow work.
  const mode = { mode: "readwrite" };
  if ((await handle.queryPermission(mode)) !== "granted" &&
      (await handle.requestPermission(mode)) !== "granted") {
    setStatus(`Write access to ${handle.name} was not granted`);
    return;
  }
  const blob = await renderRotated(item.file, rotation);
  const writable = await handle.createWritable();
  try {
    await writable.write(blob);
    await writable.close(); // Replaces the original atomically.
  } catch (err) {
    await writable.abort().catch(() => {});
    throw err;
  }
  // Refresh the snapshot: the old File object is now unreadable.
  URL.revokeObjectURL(item.url);
  item.file = await handle.getFile();
  item.url = URL.createObjectURL(item.file);
  renderStrip();
  await show(index);
  setStatus(`Saved ${handle.name}`);
}

// ---------------------------------------------------------------------------
// Fallback inputs: Open button and drag and drop
// ---------------------------------------------------------------------------
async function openWithPicker() {
  if ("showOpenFilePicker" in window) {
    const handles = await window.showOpenFilePicker({
      id: "images",
      startIn: "pictures",
      multiple: true,
      types: [{
        description: "Images",
        accept: { "image/*": [".png", ".jpg", ".jpeg", ".jfif", ".webp", ".gif", ".avif", ".svg"] },
      }],
    });
    return addHandles(handles);
  }
  const input = Object.assign(document.createElement("input"), {
    type: "file",
    accept: "image/*",
    multiple: true,
  });
  input.addEventListener("change", () => {
    addEntries([...input.files].map((file) => ({ handle: null, file }))).catch(report);
  });
  input.click();
}

document.addEventListener("dragover", (event) => {
  if ([...event.dataTransfer.items].some((item) => item.kind === "file")) {
    event.preventDefault();
  }
});
document.addEventListener("drop", (event) => {
  const fileItems = [...event.dataTransfer.items].filter((i) => i.kind === "file");
  if (fileItems.length === 0) return;
  event.preventDefault();
  // Extract synchronously: the DataTransfer is cleared after this handler.
  if ("getAsFileSystemHandle" in DataTransferItem.prototype) {
    const pending = fileItems.map((item) => item.getAsFileSystemHandle());
    Promise.all(pending)
      .then((handles) => addHandles(handles.filter((h) => h?.kind === "file")))
      .catch(report);
  } else {
    const files = fileItems.map((item) => item.getAsFile()).filter(Boolean);
    addEntries(files.map((file) => ({ handle: null, file }))).catch(report);
  }
});

// ---------------------------------------------------------------------------
// Session restore
// ---------------------------------------------------------------------------
async function restoreSession({ interactive }) {
  const handles = await loadSession();
  if (handles.length === 0 || items.length > 0) return;
  const usable = [];
  for (const handle of handles) {
    let state = await handle.queryPermission({ mode: "read" });
    if (state === "prompt" && interactive) {
      try {
        // Chrome 122+ shows one restore prompt listing all previous files.
        state = await handle.requestPermission({ mode: "read" });
      } catch {
        // SecurityError: activation used up by an earlier prompt. Skip.
      }
    }
    if (state === "granted") usable.push(handle);
  }
  if (usable.length > 0) {
    $("#restore").hidden = true;
    await addHandles(usable);
  } else if (!interactive) {
    $("#restore").hidden = false; // Needs a click to ask for permission.
  }
}

// ---------------------------------------------------------------------------
// Wiring
// ---------------------------------------------------------------------------
$("#open").addEventListener("click", () => openWithPicker().catch(report));
$("#restore").addEventListener("click", () =>
  restoreSession({ interactive: true }).catch(report));
$("#prev").addEventListener("click", () => show(index - 1).catch(report));
$("#next").addEventListener("click", () => show(index + 1).catch(report));
$("#rotate").addEventListener("click", rotate);
$("#save").addEventListener("click", () => saveRotation().catch(report));

document.addEventListener("keydown", (event) => {
  if (event.key === "ArrowLeft") show(index - 1).catch(report);
  else if (event.key === "ArrowRight") show(index + 1).catch(report);
  else if (event.key.toLowerCase() === "r" && !event.ctrlKey && !event.metaKey) rotate();
  else if (event.key.toLowerCase() === "s" && (event.ctrlKey || event.metaKey)) {
    event.preventDefault();
    saveRotation().catch(report);
  }
});

// Restore silently when persistent permission makes that possible (an
// installed desktop app keeps grants); otherwise offer a button. A launch
// that arrives meanwhile wins, because restore skips a non-empty viewer.
restoreSession({ interactive: false }).catch(report);

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/viewer/sw.js", { scope: "/viewer/" }).catch(report);
}
viewer/sw.js
// Minimal offline support: precache the app shell, serve it cache-first.
const CACHE = "pixel-viewer-v1";
const SHELL = [
  "/viewer/",
  "/viewer/viewer.css",
  "/viewer/viewer.js",
  "/viewer/session-store.js",
  "/viewer/manifest.webmanifest",
];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))),
    ),
  );
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET" || new URL(request.url).origin !== location.origin) return;
  event.respondWith(
    caches.match(request, { ignoreSearch: request.mode === "navigate" })
      .then((cached) => cached ?? fetch(request)),
  );
});

Files opened from disk never touch the service worker or the network: they arrive as handles and are displayed through blob: URLs, so the viewer works offline for local images as long as its shell is cached. The caching strategy is deliberately minimal; see Caching Strategies for update-friendly alternatives.

Common pitfalls

  1. Testing in a tab. File handling needs an installed app window. launchQueue exists in tabs too, but the OS never launches a tab with files.
  2. Only MIME types, or only extensions. Windows ignores MIME types, and Bubblewrap-generated Android intent filters use only MIME types; give both.
  3. Generic handlers first. Files go to the first matching handler in manifest order; a catch-all handler listed first swallows files meant for a specific one.
  4. Relying on reload to reopen files. Since Chrome 146 a reload starts empty. Persist handles.
  5. Writing from the consumer. Launched handles are read-only in Chromium, and the write prompt needs a user gesture. Ask when the user clicks Save.
  6. Clicking "Don't open" with "Remember my choice" during development. The app vanishes from Open with. Re-enable it in chrome://app-settings/<app-id>.
  7. Expecting launch_type to work on Windows. Every file is its own launch there; merge in the consumer with focus-existing.
  8. Forgetting double extensions. .gz does not match backup.tar.gz in Chromium; declare .tar.gz.
  9. Expecting custom document icons. icons in a file handler is not implemented; the OS shows the app icon or a generic one.
  10. Changing the manifest id. It creates a different app with fresh OS registrations and permissions.

Debugging

  • Nothing in Open with: confirm installation with OS integration and the parsed handlers in chrome://web-app-internals, the toggle in chrome://app-settings/<app-id>, and the parser warnings in DevTools Application > Manifest. On Linux, rebuild the desktop database; on macOS, LaunchServices can take a moment to notice a new app shim.
  • The app opens but shows nothing: the consumer was not set (check for an exception before setConsumer()), it was set after an await that never resolved, or it received a launch with files for a handler whose action page does not include the consumer. Log every LaunchParams you receive.
  • A new window per file: the default client_mode on desktop behaves like navigate-new. Set focus-existing.
  • NotAllowedError when saving: the handle only has read access. Request "readwrite" from a click handler first.
  • NotFoundError on restored handles: the file was moved, renamed or deleted since it was opened; drop it from the session.
  • Files ignored after a manifest change: the update may not have applied yet (see App Identity & Updates), and the user's approval was reset, so the launch dialog appears again.

Further reading

On this site

External references