Accessing Files & File Handler – Real-World PWA: The Making Of Paint.Js.Org – Part 4

In this fourth part of the series about the Microsoft Paint remake on paint.js.org, I want to demonstrate how you can save your drawings to your local disk, read them back later and how to add your web app as a handler for certain file extensions.

In this article:

cl-neu
Christian Liebel is consultant at Thinktecture, focuses on web standards and Progressive Web Applications, and is Thinktecture's representative at the W3C.

Paint is a traditional productivity app using a file-based workflow: Typically, you create a new drawing within the application and save it to the local file system. To open the drawing later again, you could locate the file using your favorite file browser and double-click it to bring Paint back up. That’s the classic workflow that users of desktop software are used to for decades. With the help of modern web APIs introduced by Project Fugu, web apps can now also integrate into this workflow.

Safe and Secure File System Access

With Google Chrome 86 (and many other Chromium-based browsers starting from the same version), the File System Access API was made generally available. This API allows developers to interact with the local file system while ensuring the user’s security and privacy. It’s not an official W3C Recommendation yet, but a draft report by the Web Incubator Community Group (WICG), the right place to discuss new APIs with other developers and browser vendors.

Among others, the API introduces two new methods on the global window object, showSaveFilePicker() and showOpenFilePicker() for saving and opening files from the local file system. To keep the API secure, those methods are only exposed for secure origins (i.e., websites delivered via HTTPS). They can only be called as a part of a user gesture (i.e., a click or key press) and only originate from the top-level document (i.e., not from a nested iframe). Furthermore, the user has to explicitly consent to the operation by selecting the target files at least once via the file picker. The API introduces a file handle that can be used to overwrite existing files, a feature that was unavailable on the web platform before. Also, the API allows dealing with files that are dragged into the browser window or tab and listing and modify directory contents. For more details, I recommend this web.dev article on the File System Access API.

At the time of this writing, Chromium-based browsers are the only ones supporting this API. Also, it is only available on desktop builds, as mobile devices typically don’t expose the file system directly to the user.

Stop Worrying about Platforms with Browser-fs-Access

Fortunately, the web platform offers a lot of fallback mechanisms to achieve similar things: On the one hand, the input[type=file] element allows you to select a file from the local file system using a file open picker. There’s even a WebKit-proprietary property (webkitdirectory) to enable the selection of directories instead of files. On the other hand, the a[download] element allows you to directly download a file (including dynamically generated blobs) to your Downloads folder. These fallback methods are available on a variety of platforms, both desktop, and mobile. Therefore, it makes sense to use a ponyfill (i.e., a polyfill that doesn’t monkey-patch global APIs) that uses the File System Access API if available and automatically falls back to an alternative approach if it isn’t. For example, there is the project browser-fs-access, a Google experiment maintained by Thomas Steiner, which I use in the Paint remake.

Saving and Opening Files

Implementing the file operations is easy: To show a file open dialog, you simply call the fileOpen() method provided by the ponyfill. It takes an array of compatible extensions, media types, and a description. The library takes care of detecting whether File System Access API is available and otherwise invokes a fallback method. This is how the Paint remake invokes the file open dialog:

				
					const file = await fileOpen({
  extensions: ['.png'],
  description: 'PNG Files',
});
drawingContext.document.handle = file.handle;
drawingContext.document.title = file.name;
updateContext(drawingContext.element);

await loadFileAndAdjustCanvas(file, drawingContext);
				
			

At the time of this writing, the Paint remake only allows selecting PNG files. If the user successfully selected a file from within the file picker, the returned promise resolves and returns a File object. However, if the user canceled the operation, the promise rejects, which could be caught in a try block.

The returned object contains a handle property if the File System Access API is supported, which can be used to overwrite the opened file later on. This property would be undefined if a fallback mechanism was used. The name property contains the file’s name. Both properties are set to Paint’s document context. For instance, the current file name is shown in the title bar of the application. Next, the loadFileAndAdjustCanvas() method is called. This method takes care of extracting the pixel data from the PNG binary format and adjusting the boundaries of the canvas accordingly.

Saving a file works similar, but the other way round: First, you need to convert the canvas’s pixel data to a binary format and then let the user pick a target file to write the binary data to. This is done by using the fileSave() method of browser-fs-access. It takes the binary data and a configuration object similar to the one from fileOpen(): In the save file picker, we also only want to offer PNG files. In contrast to the file open options, you can also specify a suggested file name that appears in the file picker as the default file name. The following code shows the Paint remake’s Save as implementation:

				
					const blob = await toBlob(canvas);
const file = await fileSave(blob, {
  fileName: document.title,
  extensions: ['.png'],
  description: 'PNG files',
});
if (file) {
  document.title = file.name;
  document.handle = file;
  updateContext(element);
}
				
			

If saving was successful and the File System Access API is available, you retrieve the handle of the saved file. In this case, the document context (including the title in the window bar) will be updated again.

If the File System Access API is supported and you have a file handle, you can also overwrite an existing file by passing the handle to the fileSave() method as a third parameter. This is how the Paint remake implements the Save menu item (in case a handle is present):

				
					await fileSave(blob, undefined, drawingContext.document.handle);
				
			

If no handle is present, the menu action calls the Save as routine.

Register Your PWA as a File Handler

Let’s say you now want to double-click an existing file and bring up your Progressive Web App again. The File Handling API, also discussed at WICG, allows exactly that: For registering your web app as a file handler, it’s required that the user installs it so that there is a wrapper with an icon and text that the operating system can show. To register your PWA as a file handler, add the file_handlers property to your Web Application Manifest like so:

				
					{
  "file_handlers": [{
    "action": "/",
    "accept": {
      "image/png": [".png"]
    }
  }]
}
				
			

The property takes an array that can contain different file handler objects. Each object has an action, representing the URL that is invoked when a file is opened. In the accept object, you enumerate the media types and file extensions that your app is compatible with. Please note that there’s no option to configure your application as the default file handler. If there are no other apps registered for the target extension, your application will become the default handler, but if there are other applications, the default app will not change. After declaring support for handling one or more filetypes and after installing the PWA to your system, the app will now appear in the list of compatible programs for files with the type according:

That’s the first part of the implementation. Next, we need to imperatively react to when the user opened our PWA from double-clicking a file. The PWA will be opened using the URL from the action property. Using the launchQueue from the global window object, the application can listen to the incoming parameters by calling the setConsumer() method. On the params object, you can find a files property that contains the list of file handles the application was opened with. From there, the Paint remake will load the binary contents of the file, and update the document context just as for the Open action:

				
					window.launchQueue.setConsumer(async (params) => {
  const [handle] = params.files;
  if (handle) {
    const file = await handle.getFile();
    drawingContext.document.title = file.name;
    drawingContext.document.handle = handle;
    await loadFileAndAdjustCanvas(file, drawingContext);
  }
});
				
			

In order to ensure the user’s security and privacy, the browser will ask for the user’s permission the first time you open a certain PWA using this way. Please note that, at the time of this writing, the File Handling API is in an early state. It’s only present in Chromium-based browsers, and users have to enable the #file-handling-api flag in order to activate the API. The API is scheduled for origin trial in Chromium versions 92—94, and if there are no major blockers, the API could ship soon thereafter.

Summary

By combining File System Access API (shipped with Chromium 86) and the upcoming File Handling API, PWAs can finally integrate into the same file-based workflow traditional productivity applications do. This enables a whole class of desktop apps to make the shift to the web finally. The Paint remake is a great demonstration that this works absolutely flawless.

In the next part of this series, I want to add another native feature to the remake: Dark mode. Especially during nighttime, a light-on-dark color scheme is easier on the eyes.

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
.NET
KP-round
.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
.NET
KP-round
Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
.NET
KP-round
.NET 8 introduces a new Garbage Collector feature called DATAS for Server GC mode - let's make some benchmarks and check how it fits into the big picture.
09.10.2023