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
acceptentry 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 theiconsmember. - Files arrive in
LaunchParams.fileson the page, not in the service worker. Chromium grants read access only; writing back triggers a permission prompt that needs a user gesture. launch_typedecides between one launch for all selected files ("single-client", default) and one launch per file ("multiple-clients"). Windows always launches once per file. Combine withlaunch_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
targetURLis 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 - Declare the file types in
file_handlers, each mapped to anactionURL inside the app's scope. - Install. Chromium writes OS-level associations: registry entries on Windows, document types in the app shim's
Info.pliston macOS, a MIME database entry and.desktopmetadata on Linux, and an Open with entry in the ChromeOS Files app. - 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.
- 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. - Deliver. Each launch opens or reuses a window according to
launch_handler, navigates it toactionif needed, and enqueues aLaunchParamsobject in that document'swindow.launchQueue. - 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:
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.
{
"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:
actionis missing, not a string, fails to parse, or resolves outsidescope;acceptis 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+jsonwith.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,.txtor.pngputs 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.gzhas the extension.tar.gz, anddata.json.gzhas.json.gz. A handler that only lists.gzdoes 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
MakefileorLICENSEthrough 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:
- 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.
- Group the files by handler. Files for different handlers are never combined into one launch.
- For a
"single-client"handler, create one launch whosefilescontains all of that handler's files. For"multiple-clients", create one launch per file. - 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¶
[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 anyawait. 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
asyncconsumer 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
LaunchParamsfor launches into app windows generally (icon, shortcut, captured link, protocol), with an emptyfilesarray. Checkfiles.lengthbefore assuming a file launch. targetURLis the handler'sactionfor file launches. Before Chrome 146 it wasnullwhen 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.
filesis typed asFileSystemHandle, and Chromium's launch plumbing can deliver a directory entry on some platforms. Checkkind. - The page, not the service worker, receives files.
launchQueueis exposed onWindowonly. 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:
- Delete any "ignore the launch if this is a reload" logic; it is dead code on current Chrome.
- 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¶
// 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 abeforeunloadguard 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_handlersis 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 thefile_handlerssection changes. - Uninstalling removes the associations (and on Windows the stored ProgIds).
- Changing the manifest
idcreates a different app with its own registrations, so settle theidbefore 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,.htmlor.mdfile opened from the Downloads folder may be hostile. Render it through<img>, a sandboxed iframe or a sanitizer, neverinnerHTML. - 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.browser1.9.0 (stable July 30, 2025; the feature arrived in 1.9.0-alpha02 in April 2025) letsTrustedWebActivityIntentcarry 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_handlersfrom the web manifest, keeps each handler'sactionand 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:
- 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. - Check what Chromium registered.
chrome://web-app-internalslists every installed app as stored by the browser, including parsed file handlers, theirlaunch_type, thelaunch_handlermode and the OS integration state. If a handler is missing there, look for a parser warning in Application > Manifest in DevTools. - 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. - 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.
- Test the dialog paths: Open without "Remember", Open with "Remember", and Don't open. Re-enable the toggle afterwards.
- Test a manifest update: change
file_handlers, let the update apply (or reinstall), and confirm the new types and the reset permission. - 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/.
# 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.
{
"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.
<!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>
// 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))) ?? [];
}
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);
}
// 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¶
- Testing in a tab. File handling needs an installed app window.
launchQueueexists in tabs too, but the OS never launches a tab with files. - Only MIME types, or only extensions. Windows ignores MIME types, and Bubblewrap-generated Android intent filters use only MIME types; give both.
- 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.
- Relying on reload to reopen files. Since Chrome 146 a reload starts empty. Persist handles.
- 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.
- 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>. - Expecting
launch_typeto work on Windows. Every file is its own launch there; merge in the consumer withfocus-existing. - Forgetting double extensions.
.gzdoes not matchbackup.tar.gzin Chromium; declare.tar.gz. - Expecting custom document icons.
iconsin a file handler is not implemented; the OS shows the app icon or a generic one. - 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 inchrome://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 anawaitthat never resolved, or it received a launch withfilesfor a handler whoseactionpage does not include the consumer. Log everyLaunchParamsyou receive. - A new window per file: the default
client_modeon desktop behaves likenavigate-new. Setfocus-existing. NotAllowedErrorwhen saving: the handle only has read access. Request"readwrite"from a click handler first.NotFoundErroron 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
- File System Access: reading, writing and permissions for the handles your app receives
- Protocol Handlers & Launch Handling:
launch_handler,launchQueueand other launch types - Advanced & Integration Members:
file_handlersalongside the other OS integration members - App Identity & Updates: how manifest changes reach installed apps
- Web Share Target: receiving files from the share sheet, the mobile counterpart of Open with
- Desktop Platforms: installation and OS integration on Windows, macOS, Linux and ChromeOS
- Trusted Web Activity: the Android wrapper that can declare file types
External references
- Manifest Incubations:
file_handlersand Web App Launch Handler API (WICG drafts) - Let installed web applications be file handlers (Chrome for Developers)
- Launch Handler API (Chrome for Developers)
- Handle files in a PWA (Microsoft Edge documentation)
- MDN: file_handlers, Associate files with your PWA, LaunchQueue, LaunchParams, launch_handler
- Chrome 146 release notes: reload and
targetURLchanges - ChromeStatus: File Handling and the File Handling explainer
- WICG/web-app-launch issue 92: the discussion behind removing launch replay on reload