Local Files, Sync Files, and External Files - Programming Chrome Apps (2014)

Programming Chrome Apps

Chapter 2. Local Files, Sync Files, and External Files

The subject of this chapter and that of Chapter 3 is storage outside of an app’s memory that persists from launch to launch. Chrome Apps can access seven types of storage (see Table 2-1), which offer the following three kinds of data structures:

files

Named files organized by a hierarchy of directories

KVP

Key-value pairs, which are a collection of keys and their values

indexed KVP objects

Key-value pairs organized into objects that are indexed for speedy access

Some storage is sandboxed, which means that it’s accessible only to the app, determined by the app ID, and kept separately for each user, determined by his or her Google account. Storage might also be synchronized (synced, for short, which we’ll use throughout this chapter and elsewhere in the book), which means that it’s automatically copied to multiple computers to keep them consistent.

As Table 2-1 shows, Local Files and IndexedDB are standard APIs available to any web page, whereas the others are only for Chrome Apps.[1] There is a standard API for Local Storage, as well, but it’s not available to Chrome Apps, and the Chrome API variant is better anyway. Some browsers, including Chrome, implement a Web SQL Database API, but it’s not available to Chrome Apps either.

We explore the first three types of storage in this chapter, Chapter 3 discusses the three KVP storage types, and Chapter 5 looks at Media Galleries.

Table 2-1. Storage available to Chrome Apps

Storage

Structure

Sandboxed?

Sync?

Chrome Apps only?

Local Files

file

yes

no

no

Sync Files

file

yes

yes

yes

External Files

file

no

no

yes

Local Storage

KVP

yes

no

yes

Sync Storage

KVP

yes

yes

yes

IndexedDB

indexed KVP objects

yes

no

no

Media Galleries

file

no

no

yes

The file-structured storage types provide different APIs for getting at files, but after you access a file, they use the same API for reading, writing, truncating, and seeking via a FileEntry object. That common API is discussed in the next section.

We’ll start out with a simple note-taking example—let’s call it the Note app to make it easy on ourselves—using Local Files. Then, we’ll enhance it to use Sync Files. Finally, we’ll turn it into a simple text editor by modifying it to use External Files. Chapter 3 continues the example by adding options for colors that are stored in Local Storage and then Sync Storage. It’s best to read this and the next chapters sequentially so that you can follow the example code.

Local Files

Local files have many of the features you’d expect: hierarchical directories, random access, and reading and writing. What they don’t have is any way to access them outside of a single app’s sandbox. They’re tied to an app ID and user and hidden very deeply in the computer’s filesystem.

To give you an idea of how hidden, the example file that I use for demonstration in this chapter is stored on my computer here:

/Users/marc/Library/Application Support/Google/Chrome/ Default/Storage/ext/jncbmmfnahjgnlehenndimadigaignfl/ def/File System/primary/p/00/00000001

The name I gave it, note.txt is nowhere to be found. That’s because it’s inside a Google LevelDB database, also stashed in an obscure place. So, a local file isn’t meant to be seen by anything outside the app that created it. That limitation aside, we can still build a note-taking app that saves its text in a local file and then retrieves it again when it’s relaunched.

NOTE

Occasionally, you need to know an app’s ID. To find it, go to the Extensions page and look in the app’s section, as illustrated in Figure 2-1.

Getting an ID for an app

Figure 2-1. Getting an ID for an app

Local files were introduced by the World Wide Web Consortium (W3C) as part of its HTML5 development, and they’re available to all Chrome web pages, not just Chrome Apps. The file API that they use is also used with the Chrome-App-only APIs, so it’s important that you understand how to use it. I’ll explain the basics here—everything we’ll need to work with the apps in this book. For more detailed information on this, read Using the HTML5 Filesystem API by Eric Bidelman (O’Reilly).

To use local files, you proceed in steps, and there are more steps than you’d think would be needed. First, you get a filesystem, then a directory entry, then a file entry, then a file writer, then a blob, and, finally, you write the blob. As we build the Note app, I’ll break down the steps into separate functions so that you can follow what’s going on. Otherwise, the cascade of callbacks would be impenetrable.

The Note app is pretty simple, as you can see in Figure 2-2. There’s a text area in which you can type and a Save button that you click to save your work. When you launch the app, what you’ve previously saved appears in the text area.

Our Note app after saving

Figure 2-2. Our Note app after saving

The app’s background.js file is exactly the same as the one for the Converter app we created in Chapter 1, and its manifest is almost the same, except for a permission that I’ll describe shortly. The index.html is about what you’d expect:

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8" />

<title>Note</title>

<script src="note.js"></script>

</head>

<body>

<p id="message"> </p>

<button id="save">Save</button>

<p><textarea id="textarea" cols="30" rows="20"></textarea></p>

</body>

</html>

FileSystem and DirectoryEntry

An app can’t just begin manipulating files without a formal introduction. It must first request a filesystem and a space allocation with a call to requestFileSystem:

requestFileSystem(type, size, successCallback, errorCallback)

type is either PERSISTENT, which means that the data you write stays until you delete it, or TEMPORARY, which means that the browser can delete the data if it wants to, like a cache. The W3C standard doesn’t say how long a browser is obligated to keep temporary storage, but you can assume it will stay around at least as long as the app is running.

The idea behind the size argument is that the user might be asked to approve that amount of space. However, Chrome Apps never do that. Instead, my testing indicates that you need to request the unlimitedStorage permission in the manifest, like this:

{

"app": {

"background": {

"scripts": [ "background.js" ]

}

},

"manifest_version": 2,

"name": "Note",

"version": "1.0.0",

"permissions": [

"unlimitedStorage"

]

}

I’ve seen some documentation that suggests the user has to agree to this permission when the app is installed, but I’ve never seen that with an actual app.

Chrome OS calls this API webkitRequestFileSystem, whereas Cordova (an Android/iOS platform for Chrome Apps, which is discussed in Appendix D) calls it requestFileSystem, so it’s best to figure out which it is and call that one. You can see this technique in the onloadhandler, which also sets up the Save button:

window.onload = function () {

var requestFileSystem = window.webkitRequestFileSystem ||

window.requestFileSystem;

requestFileSystem(PERSISTENT, 0, haveFileSystem, errorHandler);

document.querySelector("#save").addEventListener("click", save);

};

The requestFileSystem API is the first of many that we’ll see that are asynchronous. This means that it returns right away, but with its actual work deferred until later. Because JavaScript is single-threaded, that can’t happen until the onload handler returns, but when it actually happens is outside of the app’s control. When the request is ready, the success callback haveFileSystem is called (we’ll see it soon).

USING ASYNCHRONOUS CALLS CORRECTLY

If you’ve used another programming language for which system calls are mostly synchronous, you might be tempted to request a filesystem like this:

var fs = requestFileSystem(PERSISTENT, 0); // wrong

If you do this in a Chrome App, you’ll find that fs is undefined. Perhaps a more common mistake is to make the call correctly but assume that you can use the results right away:

var fileSystem;

requestFileSystem(PERSISTENT, 0,

function (fs) {

fileSystem = fs;

}

);

fileSystem.root.getDirectory("Note", gotDirectory); // wrong

Any processing that needs the FileSystem must be deferred until the requestFileSystem success callback has been called (no need for the global):

requestFileSystem(PERSISTENT, 0,

function (fs) {

fs.root.getDirectory("Note", gotDirectory);

}

);

The code in the Note app example is structured a bit differently, but you’ll see that all processing that depends on the result of an asynchronous call is properly sequenced.

The error callback is errorHandler, which goes to considerable lengths to figure out where a useful error message might be. We’ll use this error handler over and over; in the interest of brevity, I won’t show the code each time we use it in an app, so here it is:

function errorHandler(e) {

console.dir(e);

var msg;

if (e.target && e.target.error)

e = e.target.error;

if (e.message)

msg = e.message;

else if (e.name)

msg = e.name;

else if (e.code)

msg = "Code " + e.code;

else

msg = e.toString();

showMessage('Error: ' + msg);

}

Showing Transient Messages

We’ll be calling showMessage—which is what the errorHandler calls—any time we need to display a message to the user, not only in the Note app, but throughout this book. We don’t want messages to stay around forever, because after a while they’re no longer meaningful. For example, the message “Saved” is fine just after a save, but when the user begins typing it’s misleading. So, we use a timer to get rid of a message after five seconds:

var timeoutID;

function showMessage(msg, good) {

console.log(msg);

var messageElement = document.querySelector("#message");

messageElement.style.color = good ? "green" : "red";

messageElement.innerHTML = msg;

if (timeoutID)

clearTimeout(timeoutID);

timeoutID = setTimeout(

function () {

messageElement.innerHTML = " ";

},

5000

);

}

Recall that there was a <p> element with the ID message in index.html. We clear the message by setting to a nonbreaking space (rather than an empty string) so that the vertical layout in the window won’t change as messages come and go.

CURLY BRACES

In his terrific book JavaScript: The Good Parts (O’Reilly), Douglas Crockford suggests using curly braces for all if statements, even when the body is a single statement. Following those recommendations, the errorHandler function would be coded like this:

function errorHandler(e) {

console.dir(e);

var msg;

if (e.target && e.target.error) {

e = e.target.error;

}

if (e.message) {

msg = e.message;

}

else if (e.name) {

msg = e.name;

}

else if (e.code) {

msg = "Code " + e.code;

}

else {

msg = e.toString();

}

showMessage('Error: ' + msg);

}

As you’ve noticed, I don’t follow Crockford’s curly-brace advice, mostly because I don’t like the added clutter. But Crockford is probably right, and I’m a bad example.

Getting DirectoryEntrys

Getting back to the requestFileSystem call in the onload handler, the haveFileSystem function receives the requested filesystem:

var directoryEntry;

function haveFileSystem(fs) {

fs.root.getDirectory("Note",

{

create: true,

exclusive: false

},

function (de) {

directoryEntry = de;

read();

},

errorHandler

);

}

Here we get a DirectoryEntry for the directory to hold the note:

getDirectory(path, options, successCallback, errorCallback)

The path can have multiple levels, but any parent directories in it must already exist. The options argument can have two optional Boolean keys. The first, create, means that the directory is created if necessary; if create is missing or false, the call fails if the directory isn’t there. If the second option, exclusive, is true, creation fails if the directory already exists. If neither option is true, the call succeeds only if the directory exists. Most commonly, you specify create: true when you want to create the directory if needed, which is what we did here, and no options if the directory must already exist. (It’s meaningless for both create and exclusive to be true.)

There’s no concept of getting a DirectoryEntry for reading or writing; it can always do both.

You can also remove a directory and all its contents, across multiple levels:

removeRecursively(successCallback, errorCallback)

You can read the contents of a directory by using the createReader method, but I won’t go into the details.

Looking back at the success callback, we save the DirectoryEntry for later use and call read to read the note and display it in the text area. We’ll see read itself soon.

Getting FileEntrys and Creating FileWriters

Given a DirectoryEntry, you can also get a FileEntry by using the call:

getFile(path, options, successCallback, errorCallback)

This call is much like getDirectory, except that the success callback gets a FileEntry. Because there are a couple places in the Note app where we’ll need a FileEntry, there’s a function to get one:

var fileEntry;

var fileWriter;

function getFileEntry(callback) {

if (fileWriter)

callback();

else if (directoryEntry) {

directoryEntry.getFile('note.txt',

{

create: true,

exclusive: false

},

function (fe) {

fileEntry = fe;

fileEntry.createWriter(

function (fw) {

fileWriter = fw;

callback();

},

errorHandler

);

},

errorHandler

);

}

}

The only file this app needs is note.txt in the directory we already have an entry for, Note. If getFile succeeds, we save the FileEntry it got in the global fileEntry and go on to create a FileWriter by using the call:

createWriter(successCallback, errorCallback)

By now, you’re getting into the swing of things, so you’ve probably already guessed that the FileWriter is passed to the success callback—and you’re right! We save it in the global fileWriter. Then, with both the FileEntry and a FileWriter saved, we call the callback. Note that the errorHandler we saw earlier is used for the error callbacks.

Writing a File with the FileWriter API

The FileWriter methods are:

write(data);

truncate(size);

seek(offset);

You write a Blob by using write, always at the current position, which you can query (but not change) by referencing the read-only position property. To change it, you call seek, which is the only one of the three methods that’s synchronous, because all it does is set a property. Another property is length, and you can change that by using the truncate method.

BLOBS

A JavaScript Blob represents raw data constructed from an Array of parts, each of which can be, among other things, a string or a Blob, as shown in the following example:

var blob = new Blob(["Here's a string."], {type: 'text/plain'});

Note that the first argument is an Array, not a plain string.

A File object, which is what you get when you read a FileEntry, is a subclass of Blob. Think of a File as the data in a file, not the file itself as it exists on the filesystem.

There are various methods and properties of a Blob and a File that you might need at some point, but I won’t go into them here, because all we need for the Note app is the constructor. You can read all about them in a good JavaScript book such as JavaScript: The Definitive Guide by David Flanagan (O’Reilly). Or, you can go to the entry for Blob on the Mozilla Developer Network.

If a file exists, setting create: true when we get its FileEntry has no effect, unlike with most other operating systems, for which a “create” option truncates an existing file. With the FileWriter APIs, if you want to truncate the file, you must do it explicitly, and, because that’s asynchronous, you have to ensure that truncation is complete before you write. That’s what the save function (the Save button click handler) does:

function save() {

getFileEntry(

function() {

fileWriter.onwrite = function(e) {

fileWriter.onwrite = function(e) {

dirty = false;

showMessage("Saved", true);

};

var blob = new Blob([document.querySelector("#textarea").value],

{type: 'text/plain'});

fileWriter.write(blob);

};

fileWriter.onerror = errorHandler;

fileWriter.truncate(0);

}

);

}

The handler calls getFileEntry, which we just saw. When the callback is executed, we have a FileWriter (saved earlier in the global fileWriter), and we use the FileWriter API to truncate the file and write the contents of the text area to it as a Blob. (Ignore the setting of thedirty flag for now; I’ll explain it later in this chapter.)

It’s very important that the write occur after the truncate has completed, which is how the save is coded. Don’t do it this way:

fileWriter.truncate(0);

fileWriter.write(blob); // wrong -- truncate not completed

You might think that truncate is fast enough so that it’s likely to complete before the write is issued, but the single-threadedness of JavaScript makes that impossible, because the truncate can’t even begin until JavaScript execution returns to the system. For the same reason, in thesave function, it doesn’t matter whether you set the onwrite handler before or after you issue the FileWriter operation.

After you’ve written to a file, although it’s very difficult to find it on your computer’s filesystem, you can easily view it in the Developer Tools Resources tab, as shown in Figure 2-3.

note.txt shown in Developer Tools

Figure 2-3. note.txt shown in Developer Tools

Reading a File

Now let’s look at read, which was called from haveFileSystem after we got the DirectoryEntry:

function read() {

getFileEntry(

function() {

if (fileEntry)

fileEntry.file(haveFile, errorHandler);

}

);

}

function haveFile(file) {

var reader = new FileReader();

reader.onload = function() {

document.querySelector("#textarea").value = reader.result;

};

reader.readAsText(file);

}

The read function calls getFileEntry, just as save did, but this time we call the FileEntry method file to get a File object (a subclass of a Blob), which is passed to the haveFile success callback. With the File, we create a FileReader object, call its readAsText message with the File as the argument, and then stick the result into the text area when it’s available.

NOTE

It might seem strange that writing and reading are done completely differently: you get a FileWriter from the FileEntry when you want to write, but you get a File (a Blob) from the FileEntry when you want to read, and create the FileReader separately. You could argue that a FileEntry should deliver a FileReader, so reading is more like writing, but that’s not the way they designed it.

Auto-Saving and Idle Events

Unlike other platforms, such as Mac OS X or Windows, Chrome doesn’t inform an app when it’s about to terminate, which is something you might want to know about so you can save the user’s work. Such an event was omitted deliberately for Chrome Apps because it can never be relied upon, given that the application might terminate because of a computer crash or power failure. Not having the event forces you to design your application so that it continuously saves the state of the user’s work, which is a much more reliable approach.

But first, before we can implement auto-saving, we have to know if the user changed anything. In the simple Note app, there’s only the text area to worry about, and we can observe changes there by listening for four text-area events: keypress, paste, cut, and change. But, none of those four fires when the text is changed by pressing the Backspace or Delete keys, so you need to test for those actions explicitly by listening for a keydown event and checking the keyCode property of the event against 8 and 46, the codes for backspace and delete. Upon any change, a global flag, dirty, is set:

var dirty = false;

function setupAutoSave() {

var taElement = document.querySelector("#textarea");

taElement.addEventListener("keypress", didChange);

taElement.addEventListener("paste", didChange);

taElement.addEventListener("cut", didChange);

taElement.addEventListener("change", didChange);

taElement.addEventListener("keydown", didChange);

}

function didChange(e) {

if (e.type !== 'keydown' ||

e.keyCode === 8 || e.keyCode === 46) // backspace or delete

dirty = true;

}

Listening for a change event is insufficient by itself, because it only fires when the text area loses focus, and that might not happen for minutes, or even hours, which isn’t safe.

We add a call to setupAutoSave to the end of the onload handler:

window.onload = function () {

// ...

setupAutoSave();

};

Now, the dirty flag indicates whether the text has been changed since the last save. Looking back at the code for the save function, that’s why the flag was turned off with a successful save:

function save() {

// ...

fileWriter.onwrite = function(e) {

dirty = false;

showMessage("Saved", true);

};

// ...

}

There are a couple of ways to save automatically:

§ Checking the dirty flag at regular intervals, such as every 15 seconds

§ Checking the flag when the app becomes idle; that is, no user input for a defined interval

Waiting until the app is idle is a little better because there’s no point saving while the user is actively typing. That’s easy to implement with the chrome.idle API:

chrome.idle.setDetectionInterval(15);

chrome.idle.onStateChanged.addListener(

function (state) {

if (state === "idle" && dirty)

save();

}

);

The StateChanged event fires when the idle state changes, testing it every 15 seconds (the minimum allowed). It can be active, idle, or locked. The locked state means that the screen is locked or the screensaver is activated. All we care about is idle, in which case save is called if the text area is dirty (modified since the last save).

Use of chrome.idle requires that we request idle permission; thus the permissions in manifest.json need to be changed to the following:

"permissions": [

"unlimitedStorage",

"idle"

]

We’ll keep the Save button, although typically note-taking apps don’t have one (for example, Google’s Keep app doesn’t).

Sync Files

A note-taking app that keeps its note hidden away on a single computer isn’t really that useful. Modern note-takers sync their notes so that they’re available on whatever device you’re using and, as a bonus, automatically backed up. It’s surprisingly easy to modify our Note app to sync; all we need to do is use the Sync FileSystem, available only to Chrome Apps.

How Syncing Works

The Sync FileSystem keeps a local copy of directories and files, and you code input and output (I/O) between the application and that local copy, just as you do for Local Files. In the background, a system task built into Chrome copies data back and forth between the local copy and a copy on Google Drive, called the remote copy. There might be a delay of a minute or so before this syncing occurs. If syncing isn’t active, perhaps because the computer is offline, I/O between the app and the local copy proceeds normally, and syncing occurs sometime later, when the computer again has access to the Internet.

To show how the Sync FileSystem works, we’ll modify the Note app so that it syncs. Anything typed or otherwise modified is written to the Sync FileSystem (a local copy, that is) and then eventually synced with the remote copy on Google Drive. If the file is changed by another computer, an event is fired, alerting the app that the local copy changed and that the text area should be updated. Figure 2-4 illustrates our Note app running on two computers, showing the local copies of the note.txt file that contains the note, and the remote copy.

Two installations of Note synced to a shared remote copy

Figure 2-4. Two installations of Note synced to a shared remote copy

The local copy used by the Sync FileSystem isn’t the same one used by Local Files. These are two separate sandboxes.

Figure 2-5 shows the new Note app running after some text has been typed.

Note app showing file status after text has been typed

Figure 2-5. Note app showing file status after text has been typed

One thing that’s new is the file status (“pending”) at the bottom; this was put there by a function that we’ll see as we go through the code. Here’s the new index.html:

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8" />

<title>Note</title>

<script src="note.js"></script>

</head>

<body>

<p id="message"> </p>

<p><textarea id="textarea" cols="30" rows="20"></textarea></p>

<p>File Status: <span id="status"></span></p>

</body>

</html>

Making the Note App Sync

After we get a syncing FileSystem, how we deal with DirectoryEntry, FileEntry, FileWriter, and the other file objects is the same as before; therefore, we can retain most of the Note app code, and I won’t repeat those functions that are exactly the same here.

You request a sync FileSystem by calling chrome.syncFileSystem.requestFileSystem, whose only argument is the callback:

chrome.syncFileSystem.requestFileSystem(callback)

Because there’s no separate error callback, we have to check chrome.runtime.lastError, as you can see in the modified onload handler:

window.onload = function () {

chrome.syncFileSystem.requestFileSystem(

function (fs) {

if (chrome.runtime.lastError)

showMessage(chrome.runtime.lastError.message);

else

haveFileSystem(fs);

}

);

setupAutoSave();

};

We’ve tossed out the Save button, so the code to set up its event handler is gone.

On success, we call haveFileSystem, which is exactly as before. Recall that its job is to get the DirectoryEntry for the Note directory and then call the read function.

What is changed is the getFileEntry function, which both read and save call. In the earlier version, we just created the file if it didn’t exist, but now that we’re syncing, we can’t do that. Here’s the problem: suppose that the syncing Note app is installed on a new computer. Initially, there’s no note.txt file on that computer, but it’s possible that after a few seconds the current version will be synced down from Google Drive. Meanwhile, if we had created the file, that would count as a change, and it’s possible that the new empty file would overwrite the saved one.

So, for any call to read, if the file doesn’t exist, we don’t want to create it. However, for save, we do want to create a nonexistent file because we really do need to write it. We modify getFileEntry to take a first argument that indicates the value of the create option:

var fileEntry;

var fileWriter;

function getFileEntry(create, callback) {

if (fileWriter)

callback();

else if (directoryEntry) {

directoryEntry.getFile('note.txt',

{

create: create,

exclusive: false

},

function (fe) {

fileEntry = fe;

fileEntry.createWriter(

function (fw) {

fileWriter = fw;

callback();

},

errorHandler

);

},

function (e) {

if (e.name === 'NotFoundError')

callback();

else

errorHandler(e);

}

);

}

}

With this change, if create is false, an error occurs if the file doesn’t exist. Accordingly, we check for that particular error and just execute the callback in that case.

To see how the new getFileEntry is used, here’s the modified read function (called from haveFileSystem, as before):

function read() {

showFileStatus();

getFileEntry(false,

function() {

if (fileEntry)

fileEntry.file(haveFile, errorHandler);

}

);

}

function haveFile(file) {

var reader = new FileReader();

reader.onload = function() {

document.querySelector("#textarea").value = reader.result;

};

reader.readAsText(file);

}

The call to getFileEntry now has a first argument of false. If the file doesn’t exist, fileEntry is undefined, so the text area remains empty. If the file isn’t on Google Drive either, empty is how the text area should remain, until the user types. We’ll see soon what happens if the file is on Google Drive and is synced down later.

The call at the top to showFileStatus shows the file status; I’ll get to that in a bit.

The save function operates exactly as it did for local files because the first argument to the new getFileEntry is true:

function save() {

getFileEntry(true,

function() {

fileWriter.onwrite = function(e) {

fileWriter.onwrite = function(e) {

dirty = false;

showMessage("Saved", true);

};

var blob = new Blob([document.querySelector("#textarea").value],

{type: 'text/plain'});

fileWriter.write(blob);

};

fileWriter.onerror = errorHandler;

fileWriter.truncate(0);

}

);

}

Now that there is no longer a Save button, save is called only from the chrome.idle.onStateChanged event handler.

Listening for the FileStatusChanged Event

The app needs to show the note if it has been synchronized down from Google Drive. To do that, it sets a listener for the chrome.syncFileSystem.onFileStatusChanged event:

chrome.syncFileSystem.onFileStatusChanged.addListener(

function (detail) {

if (detail.fileEntry.name === "note.txt") {

showMessage(detail.fileEntry.name + " • " +

detail.direction + " • " + detail.action +

" • " + detail.status, true);

if (detail.direction === 'remote_to_local' &&

detail.status === 'synced')

read();

showFileStatus(detail.status);

}

}

);

When the event fires, it means that the background syncing has changed the file status, and the event handler is called with a detail object that has the following four properties:

fileEntry

The FileEntry for the file whose status has changed. It has two interesting properties: name, which we’re using in the example code, and path.

status

One of synced, pending, or conflicting. A conflict occurs if two or more local copies on different devices have been changed. By default, that won’t occur, because the last writer prevails. However, you can call chrome.syncFileSystem.setConflictResolutionPolicy to set the policy to manual, in which case conflicts won’t be resolved automatically. The default is last_write_win, and that’s what you’ll almost always want.

action

Set if the status is synced; one of added, updated, or deleted.

direction

Either local_to_remote or remote_to_local.

In the code, all four properties are used to display a status message by using showMessage, and the file status is shown by showFileStatus, which we’ll see in a moment. For example, after I typed on my Mac, the message in Figure 2-6 appeared after a few seconds.

Figure 2-7 shows the Note app running on my Windows computer after I typed a bit more text.

You can see that local changes were copied up to the remote (Google Drive), which were then copied down. All of this was entirely automatic. The application only knew about the change to the remote copy from the event that was fired.

The Note app after local-to-remote sync

Figure 2-6. The Note app after local-to-remote sync

The Note app after remote-to-local sync

Figure 2-7. The Note app after remote-to-local sync

Going back to the event handler, the purpose of the following code is to show the note if it was just copied from Google Drive:

if (detail.direction === 'remote_to_local' &&

detail.status === 'synced')

read();

That’s what causes all instances of the app to show the same text after syncing. If the file didn’t exist when the app was first started, causing read to do nothing back then, when the event fires the file will exist and the call to read will display it in the text area.

We don’t care to do anything when a local_to_remote event occurs, other than to show the status on the screen. Because the text being synced is already in the text area, it doesn’t have to be updated.

Showing File Status

The showFileStatus function updates the status shown at the bottom of the window. It uses the chrome.syncFileSystem.getFileStatus API to get the status if none was passed in. If there’s no FileEntry because no local copy yet exists, the notation “no local copy” is displayed:

function showFileStatus(status) {

var statusElement = document.querySelector("#status");

if (status)

statusElement.innerHTML = status;

else

getFileEntry(false,

function() {

if (fileEntry)

chrome.syncFileSystem.getFileStatus(fileEntry,

function (status) {

statusElement.innerHTML = status;

}

);

else

statusElement.innerHTML = "no local copy";

}

);

}

There’s also a call to showFileStatus added to the end of showMessage. This causes the status to display whenever a message (such as “Saved”) is shown:

function showMessage(msg, good) {

// ...

showFileStatus();

}

Finding Remote Copies on Google Drive

If you browse your directories on Google Drive, you won’t find the remote copies. However, if you really want to see them, you can open up your Google Drive in a browser window and click the All Items link at the left, as illustrated in Figure 2-8.

Sync directory and file on Google Drive

Figure 2-8. Sync directory and file on Google Drive

It’s undocumented what happens if you make changes directly on Google Drive—they might not be synced—so don’t do it. File access should be only through the Sync FileSystem APIs.

External Files

Local Files are of limited use because its infeasible to find them on the local filesystem and, even if you could find them, how and where they’re stored isn’t documented and probably will change at some point. That limits such files to data completely internal to the application, which is useless for applications such as text editors or image viewers. Sync Files have their uses, of course, but they also are captives of the app’s sandbox.

However, because Chrome Apps run with much tighter security than ordinary web apps, they can use an API that provides access to any user-visible directory or file on the computer, with only one restriction: the user must explicitly choose the file or the directory it’s in, via file-open or file-save dialogs, the same ones you see on native apps. (Media files are an exception; see The mediaGalleries API for an alternative way to access them.)

External Files use the same file API as Local Files and Sync Files, except for two differences:

§ You use chrome.fileSystem.chooseEntry to show a file-open/save dialog that provides a FileEntry object. When you have one of those, you manipulate it the usual way, as we’ve been doing.

§ You don’t have to request a filesystem, because chrome.fileSystem.chooseEntry knows what filesystem to use.

A Simple Editor

Figure 2-9 shows the Simple Editor example app that I’ll use to illustrate the use of the chrome.fileSystem API:

The Simple Editor app

Figure 2-9. The Simple Editor app

After typing some text into the text area and clicking the Save button, a standard save-file dialog opens, as shown in Figure 2-10.

The Simple Editor save-file dialog

Figure 2-10. The Simple Editor save-file dialog

To open another file, I can click the Open button to get a standard file-open dialog, as depicted in Figure 2-11.

Simple Editor is based on the Note app that we worked on earlier in this chapter and resembles it in many ways. Here’s the index.html file:

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8" />

<title>SimpleEditor</title>

<script src="lib/Dialogs.js"></script>

<script src="SimpleEditor.js"></script>

</head>

<body>

<button id="new">New</button>

<button id="open">Open...</button>

<button id="save">Save</button>

<button id="saveas">Save As...</button>

<p id="message"> </p>

<textarea id="textarea" cols="60" rows="20" style='outline: none;'></textarea>

</body>

</html>

The Simple Editor open-file dialog

Figure 2-11. The Simple Editor open-file dialog

Note that the Dialogs.js file (see Appendix A) is included so that the Simple Editor app can pop up a modal dialog, as we’ll see a little later.

IS A TEXT AREA THE RIGHT CHOICE?

A text area is fine for the examples in this book, but it falls well short of what you need for a real text editor. You’ll likely want find-and-replace, syntax highlighting, line numbers, automatic indent and outdent, and much more. The open source, embeddable code editor Ace [ace.c9.io] is a better choice, and it works well in Chrome Apps. It’s what the Chrome Dev Editor uses.

All of the JavaScript is in the window.onload handler; this ensures that all of the elements defined in the HTML have been created:

window.onload = function () {

// ... entire app goes here ...

};

Setting up handlers for the buttons and for reacting to changes in the text area is very close to what we’ve already seen:

var taElement = document.querySelector("#textarea");

var dirty = false;

document.querySelector("#new").addEventListener("click", newFile);

document.querySelector("#open").addEventListener("click", openFile);

document.querySelector("#save").addEventListener("click", saveFile);

document.querySelector("#saveas").addEventListener("click", saveFileAs);

taElement.addEventListener("keypress", didChange);

taElement.addEventListener("paste", didChange);

taElement.addEventListener("cut", didChange);

taElement.addEventListener("change", didChange);

taElement.addEventListener("keydown", didChange);

function didChange(e) {

if (e.type !== 'keydown' ||

e.keyCode === 8 || e.keyCode === 46) // backspace or delete

dirty = true;

}

The newFile and openFile functions store new contents into the text area, so it’s necessary to first check the dirty flag, which is done by dirtyCheck:

function dirtyCheck(callback) {

if (dirty)

Dialogs.confirm('Discard changes?', 'Discard', 'Keep', callback);

else

callback();

}

If dirty is set, the user is asked whether what’s been typed should be discarded (Figure 2-12).

The confirm dialog asking the user whether changes can be discarded

Figure 2-12. The confirm dialog asking the user whether changes can be discarded

See Appendix A for how Dialogs.confirm is used. If the user chooses to discard the text, or if the flag is unset, the callback is called. If dirty is set but the user wants to keep the text, the callback isn’t called. That’s what’s done in newFile, the New button’s event handler:

function newFile() {

dirtyCheck(

function() {

fileEntry = null;

taElement.value = "";

taElement.focus();

dirty = false;

document.title = 'Simple Editor - [new]';

}

);

}

If it’s OK to clear the text area, the dirty flag is turned off and the document name is reset.

Choosing Files and Directories

Here is the API for choosing a file:

chrome.fileSystem.chooseEntry(options, callback)

option has a type property with one of the following three values:

openFile

Get a FileEntry for an existing file.

saveFile

Get a FileEntry for a file to be written.

openDirectory

Get a DirectoryEntry.

The option you pick interacts with the permissions in the manifest. You need at least fileSystem permission, which allows you to open a file for reading and nothing else:

"permissions": [

"fileSystem"

]

If you add write, you can also save a file, and any file opened is available for both reading and writing, which is what Simple Editor needs:

"permissions": [

{

"fileSystem": [

"write"

]

}

]

If you add the directory permission, you can use the openDirectory option. To enable writing, you must also have asked for write permission, like this:

"permissions": [

{

"fileSystem": [

"directory",

"write"

]

}

]

If chrome.fileSystem.chooseEntry isn’t canceled by the user, the callback is called with a FileEntry as an argument, and you can use any of the methods that we saw earlier in this chapter, just as with Local Files and Sync Files.

Now, the openFile function—the Open button’s event handler—should be understandable, especially as we’ve already seen how a FileEntry is manipulated:

var fileEntry;

function openFile() {

dirtyCheck(

function() {

chrome.fileSystem.chooseEntry(

{

type: 'openFile'

},

function (fe) {

if (fe) {

fileEntry = fe;

fe.file(

function (file) {

var reader = new FileReader();

reader.onloadend = function(e) {

taElement.value = this.result;

taElement.focus();

dirty = false;

showMessage('Opened OK', true);

document.title = 'Simple Editor - ' + fe.name;

};

reader.readAsText(file);

},

errorHandler

);

}

}

);

}

);

}

There are two things to note here: we save the FileEntry in a global variable, and the dirty flag is turned off.

The saveFile function also calls chrome.fileSystem.chooseEntry but with a different option:

function saveFile() {

if (fileEntry)

save();

else

chrome.fileSystem.chooseEntry(

{

type: 'saveFile'

},

function (fe) {

if (fe) {

fileEntry = fe;

save();

document.title = 'Simple Editor - ' + fe.name;

}

}

);

}

Keep in mind that if the global fileEntry is set, the file to be saved is the same one that was opened, so we just save without prompting the user for a filename.

The actual work of saving is done by save—the Save button’s event handler—which is almost identical to the one in the Note app:

function save() {

fileEntry.createWriter(

function(fileWriter) {

fileWriter.onerror = errorHandler;

fileWriter.onwrite = function(e) {

fileWriter.onwrite = function(e) {

showMessage('Saved OK', true);

dirty = false;

taElement.focus();

};

var blob = new Blob([taElement.value],

{type: 'text/plain'});

fileWriter.write(blob);

};

fileWriter.truncate(0);

},

errorHandler

);

}

Observe that once more we turn off the dirty flag.

Given the way saveFile is constructed, saveFileAs (the “Save As” button’s event handler), can piggyback on it by just killing fileEntry first:

function saveFileAs() {

fileEntry = null;

saveFile();

}

The showMessage and errorHandler functions are almost identical to those we’ve already seen, so I’m not going to show them again.

Adding Backup by Using Retained File Entries

Suppose that you want to add a backup feature to Simple Editor so that it automatically keeps a second copy when a file is saved. The obvious way to do that is for the user to choose a backup directory, and then the app can create as many files as you want in that directory.

However, it’s inconvenient for the user to choose the backup directory every time the app is launched. That should be done just once, when the app is used for the first time, or maybe on rare occasions when the user wants to change it.

To accommodate this, the chrome.fileSystem API has retained file entries that can be saved and reused without having to create them fresh each time with chrome.fileSystem.chooseEntry. The rule that the user must have chosen the directory is still obeyed, just maybe sometime in the distant past.

A good place to save the retained entry is in local storage, along with other app preferences. We’ll see the local-storage API in the next chapter; for now we’ll use two higher-level functions whose code we’ll defer showing until then:

setParams(x);

getParams(x, callback);

For setParams, the argument is an object that’s saved in local storage, like this:

setParams({ BackupFolderID: entryID });

For getParams, the argument is a key whose value is wanted, or an array of such keys, and the retrieved keys and their values are passed as an object to the callback function, like this:

getParams("BackupFolderID",

function (items) {

// do something with items.BackupFolderID

}

);

To see how all this works in practice, we’ll modify Simple Editor to back up any saved files. I’ll show only the new stuff, as most of the app won’t change.

Figure 2-13 shows Simple Editor after the backup has been set with a standard directory-choosing dialog. (We’ll see how shortly.) The chosen backup path is momentarily displayed.

Simple Editor after setting the backup directory

Figure 2-13. Simple Editor after setting the backup directory

In the manifest, we need directory and retainEntries permissions for the fileSystem. We also need storage permission so that we can save the DirectoryEntry for backups in local storage with setParams and getParams:

"permissions": [

{

"fileSystem": [

"write",

"directory",

"retainEntries"

]

},

"storage"

]

Here’s a function, setBackup, to set the backup (we’ll see where it’s called later):

var directoryEntryBackup;

function setBackup() {

chrome.fileSystem.chooseEntry({

type: 'openDirectory'

},

function (entry) {

if (entry) {

directoryEntryBackup = entry;

var entryID = chrome.fileSystem.retainEntry(entry);

setParams({ BackupFolderID: entryID });

show_backup_folder();

}

else

showMessage("No folder chosen");

});

}

The only thing here that we haven’t already seen is this line:

var entryID = chrome.fileSystem.retainEntry(entry);

This causes the DirectoryEntry to be retained and returns an ID to it that, unlike the DirectoryEntry itself, can be saved externally, which we do by saving it in local storage with setParams.

Just to confirm that it was set, we call show_backup_folder to display the path to the backup directory:

function show_backup_folder() {

if (directoryEntryBackup)

chrome.fileSystem.getDisplayPath(directoryEntryBackup,

function (path) {

showMessage('Backup Folder: ' + path, true);

});

else

showMessage('No backup folder');

}

We use the chrome.fileSystem.getDisplayPath API call to get a displayable path.

To take advantage of the backup directory, we have to change the save function to save the file twice: once where the user wanted to save it, and once to the backup directory. The saveToEntry function handles the actual saving, given a FileEntry and a callback to be called when the file is saved:

function saveToEntry(fe, callback) {

fe.createWriter(

function(fileWriter) {

fileWriter.onerror = errorHandler;

fileWriter.onwrite = function(e) {

fileWriter.onwrite = function(e) {

callback();

};

var blob = new Blob([taElement.value],

{type: 'text/plain'});

fileWriter.write(blob);

};

fileWriter.truncate(0);

},

errorHandler

);

}

Now, we can implement the double save easily by calling saveToEntry twice (look in the middle for the second call):

function save() {

saveToEntry(fileEntry,

function () {

dirty = false;

taElement.focus();

if (directoryEntryBackup)

directoryEntryBackup.getFile(fileEntry.name,

{

create: true,

exclusive: false

},

function (fe) {

saveToEntry(fe,

function () {

showMessage('Saved/Backedup OK', true);

}

);

},

errorHandler

);

else

showMessage('Saved/OK (no backup)', true);

}

);

}

It’s not an error if the user never set a backup directory, but we do want to indicate that no backup was saved, which we do by displaying the message. Also, we don’t bother keeping the FileEntry for the backup around, as we did for the primary file, but just get a fresh one each time.

So, that all works, but the entire point of a retained entry is that the user doesn’t have to set it each time the app launches. Because the ID is in local storage, all we need to do is add this code, which executes on every launch:

getParams("BackupFolderID",

function (items) {

if (chrome.runtime.lastError)

showMessage('Unable to get backup folder ID. (' +

chrome.runtime.lastError.message + ')');

else if (items && items.BackupFolderID)

chrome.fileSystem.restoreEntry(items.BackupFolderID,

function (entry) {

directoryEntryBackup = entry;

show_backup_folder();

}

);

else

setBackup();

}

);

Note the call to chrome.fileSystem.restoreEntry to get a DirectoryEntry back from the stored ID. If there’s no backup directory set, setBackup (which you just saw) is called to set it. This happens when the app is first launched; subsequent launches find that it’s already set.

One toy-like limitation of this example is that there’s no directory tree in the backup directory, so files of the same name but in different directories will overwrite one another. What it takes to fix this defect is to use the complete path of the FileEntry to create all of the needed intermediate directories before saving the backup, a task I leave to you.

Here’s another limitation: there’s not only no auto-save, but there’s not even a warning when the app exits. That’s not really a good design—it’s better to use the method that we used in the Note app to save automatically. Because it’s a text editor, users probably expect Save and Save As buttons, so you might want to retain those.

We’re not finished with our editor. In Chapter 3, we’ll add options to set the text colors as well as a button with which the user can change the backup directory.

Chapter Summary

In this chapter, we discussed how to use Local Files, Sync Files, and External Files using HTML5 APIs and APIs unique to Chrome Apps. Our exploration continues in Chapter 3 with the key-value-pair APIs.


[1] Some are available to Extensions, but my focus is on Chrome Apps.