michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: module.metadata = { michael@0: "stability": "stable" michael@0: }; michael@0: michael@0: const { Cc, Ci, Cu } = require("chrome"); michael@0: const file = require("./io/file"); michael@0: const prefs = require("./preferences/service"); michael@0: const jpSelf = require("./self"); michael@0: const timer = require("./timers"); michael@0: const unload = require("./system/unload"); michael@0: const { emit, on, off } = require("./event/core"); michael@0: const { defer } = require('./core/promise'); michael@0: michael@0: const { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); michael@0: michael@0: const WRITE_PERIOD_PREF = "extensions.addon-sdk.simple-storage.writePeriod"; michael@0: const WRITE_PERIOD_DEFAULT = 300000; // 5 minutes michael@0: michael@0: const QUOTA_PREF = "extensions.addon-sdk.simple-storage.quota"; michael@0: const QUOTA_DEFAULT = 5242880; // 5 MiB michael@0: michael@0: const JETPACK_DIR_BASENAME = "jetpack"; michael@0: michael@0: Object.defineProperties(exports, { michael@0: storage: { michael@0: enumerable: true, michael@0: get: function() { return manager.root; }, michael@0: set: function(value) { manager.root = value; } michael@0: }, michael@0: quotaUsage: { michael@0: get: function() { return manager.quotaUsage; } michael@0: } michael@0: }); michael@0: michael@0: function getHash(data) { michael@0: let { promise, resolve } = defer(); michael@0: michael@0: let crypto = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); michael@0: crypto.init(crypto.MD5); michael@0: michael@0: let listener = { michael@0: onStartRequest: function() { }, michael@0: michael@0: onDataAvailable: function(request, context, inputStream, offset, count) { michael@0: crypto.updateFromStream(inputStream, count); michael@0: }, michael@0: michael@0: onStopRequest: function(request, context, status) { michael@0: resolve(crypto.finish(false)); michael@0: } michael@0: }; michael@0: michael@0: let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. michael@0: createInstance(Ci.nsIScriptableUnicodeConverter); michael@0: converter.charset = "UTF-8"; michael@0: let stream = converter.convertToInputStream(data); michael@0: let pump = Cc["@mozilla.org/network/input-stream-pump;1"]. michael@0: createInstance(Ci.nsIInputStreamPump); michael@0: pump.init(stream, -1, -1, 0, 0, true); michael@0: pump.asyncRead(listener, null); michael@0: michael@0: return promise; michael@0: } michael@0: michael@0: function writeData(filename, data) { michael@0: let { promise, resolve, reject } = defer(); michael@0: michael@0: let stream = file.open(filename, "w"); michael@0: try { michael@0: stream.writeAsync(data, err => { michael@0: if (err) michael@0: reject(err); michael@0: else michael@0: resolve(); michael@0: }); michael@0: } michael@0: catch (err) { michael@0: // writeAsync closes the stream after it's done, so only close on error. michael@0: stream.close(); michael@0: reject(err); michael@0: } michael@0: michael@0: return promise; michael@0: } michael@0: michael@0: // A generic JSON store backed by a file on disk. This should be isolated michael@0: // enough to move to its own module if need be... michael@0: function JsonStore(options) { michael@0: this.filename = options.filename; michael@0: this.quota = options.quota; michael@0: this.writePeriod = options.writePeriod; michael@0: this.onOverQuota = options.onOverQuota; michael@0: this.onWrite = options.onWrite; michael@0: this.hash = null; michael@0: unload.ensure(this); michael@0: this.startTimer(); michael@0: } michael@0: michael@0: JsonStore.prototype = { michael@0: // The store's root. michael@0: get root() { michael@0: return this.isRootInited ? this._root : {}; michael@0: }, michael@0: michael@0: // Performs some type checking. michael@0: set root(val) { michael@0: let types = ["array", "boolean", "null", "number", "object", "string"]; michael@0: if (types.indexOf(typeof(val)) < 0) { michael@0: throw new Error("storage must be one of the following types: " + michael@0: types.join(", ")); michael@0: } michael@0: this._root = val; michael@0: return val; michael@0: }, michael@0: michael@0: // True if the root has ever been set (either via the root setter or by the michael@0: // backing file's having been read). michael@0: get isRootInited() { michael@0: return this._root !== undefined; michael@0: }, michael@0: michael@0: // Percentage of quota used, as a number [0, Inf). > 1 implies over quota. michael@0: // Undefined if there is no quota. michael@0: get quotaUsage() { michael@0: return this.quota > 0 ? michael@0: JSON.stringify(this.root).length / this.quota : michael@0: undefined; michael@0: }, michael@0: michael@0: startTimer: function JsonStore_startTimer() { michael@0: timer.setTimeout(() => { michael@0: this.write().then(this.startTimer.bind(this)); michael@0: }, this.writePeriod); michael@0: }, michael@0: michael@0: // Removes the backing file and all empty subdirectories. michael@0: purge: function JsonStore_purge() { michael@0: try { michael@0: // This'll throw if the file doesn't exist. michael@0: file.remove(this.filename); michael@0: this.hash = null; michael@0: let parentPath = this.filename; michael@0: do { michael@0: parentPath = file.dirname(parentPath); michael@0: // This'll throw if the dir isn't empty. michael@0: file.rmdir(parentPath); michael@0: } while (file.basename(parentPath) !== JETPACK_DIR_BASENAME); michael@0: } michael@0: catch (err) {} michael@0: }, michael@0: michael@0: // Initializes the root by reading the backing file. michael@0: read: function JsonStore_read() { michael@0: try { michael@0: let str = file.read(this.filename); michael@0: michael@0: // Ideally we'd log the parse error with console.error(), but logged michael@0: // errors cause tests to fail. Supporting "known" errors in the test michael@0: // harness appears to be non-trivial. Maybe later. michael@0: this.root = JSON.parse(str); michael@0: let self = this; michael@0: getHash(str).then(hash => this.hash = hash); michael@0: } michael@0: catch (err) { michael@0: this.root = {}; michael@0: this.hash = null; michael@0: } michael@0: }, michael@0: michael@0: // Cleans up on unload. If unloading because of uninstall, the store is michael@0: // purged; otherwise it's written. michael@0: unload: function JsonStore_unload(reason) { michael@0: timer.clearTimeout(this.writeTimer); michael@0: this.writeTimer = null; michael@0: michael@0: if (reason === "uninstall") michael@0: this.purge(); michael@0: else michael@0: this.write(); michael@0: }, michael@0: michael@0: // True if the root is an empty object. michael@0: get _isEmpty() { michael@0: if (this.root && typeof(this.root) === "object") { michael@0: let empty = true; michael@0: for (let key in this.root) { michael@0: empty = false; michael@0: break; michael@0: } michael@0: return empty; michael@0: } michael@0: return false; michael@0: }, michael@0: michael@0: // Writes the root to the backing file, notifying write observers when michael@0: // complete. If the store is over quota or if it's empty and the store has michael@0: // never been written, nothing is written and write observers aren't notified. michael@0: write: Task.async(function JsonStore_write() { michael@0: // Don't write if the root is uninitialized or if the store is empty and the michael@0: // backing file doesn't yet exist. michael@0: if (!this.isRootInited || (this._isEmpty && !file.exists(this.filename))) michael@0: return; michael@0: michael@0: let data = JSON.stringify(this.root); michael@0: michael@0: // If the store is over quota, don't write. The current under-quota state michael@0: // should persist. michael@0: if ((this.quota > 0) && (data.length > this.quota)) { michael@0: this.onOverQuota(this); michael@0: return; michael@0: } michael@0: michael@0: // Hash the data to compare it to any previously written data michael@0: let hash = yield getHash(data); michael@0: michael@0: if (hash == this.hash) michael@0: return; michael@0: michael@0: // Finally, write. michael@0: try { michael@0: yield writeData(this.filename, data); michael@0: michael@0: this.hash = hash; michael@0: if (this.onWrite) michael@0: this.onWrite(this); michael@0: } michael@0: catch (err) { michael@0: console.error("Error writing simple storage file: " + this.filename); michael@0: console.error(err); michael@0: } michael@0: }) michael@0: }; michael@0: michael@0: michael@0: // This manages a JsonStore singleton and tailors its use to simple storage. michael@0: // The root of the JsonStore is lazy-loaded: The backing file is only read the michael@0: // first time the root's gotten. michael@0: let manager = ({ michael@0: jsonStore: null, michael@0: michael@0: // The filename of the store, based on the profile dir and extension ID. michael@0: get filename() { michael@0: let storeFile = Cc["@mozilla.org/file/directory_service;1"]. michael@0: getService(Ci.nsIProperties). michael@0: get("ProfD", Ci.nsIFile); michael@0: storeFile.append(JETPACK_DIR_BASENAME); michael@0: storeFile.append(jpSelf.id); michael@0: storeFile.append("simple-storage"); michael@0: file.mkpath(storeFile.path); michael@0: storeFile.append("store.json"); michael@0: return storeFile.path; michael@0: }, michael@0: michael@0: get quotaUsage() { michael@0: return this.jsonStore.quotaUsage; michael@0: }, michael@0: michael@0: get root() { michael@0: if (!this.jsonStore.isRootInited) michael@0: this.jsonStore.read(); michael@0: return this.jsonStore.root; michael@0: }, michael@0: michael@0: set root(val) { michael@0: return this.jsonStore.root = val; michael@0: }, michael@0: michael@0: unload: function manager_unload() { michael@0: off(this); michael@0: }, michael@0: michael@0: new: function manager_constructor() { michael@0: let manager = Object.create(this); michael@0: unload.ensure(manager); michael@0: michael@0: manager.jsonStore = new JsonStore({ michael@0: filename: manager.filename, michael@0: writePeriod: prefs.get(WRITE_PERIOD_PREF, WRITE_PERIOD_DEFAULT), michael@0: quota: prefs.get(QUOTA_PREF, QUOTA_DEFAULT), michael@0: onOverQuota: emit.bind(null, exports, "OverQuota") michael@0: }); michael@0: michael@0: return manager; michael@0: } michael@0: }).new(); michael@0: michael@0: exports.on = on.bind(null, exports); michael@0: exports.removeListener = function(type, listener) { michael@0: off(exports, type, listener); michael@0: };