1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/addon-sdk/source/lib/sdk/simple-storage.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,296 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +module.metadata = { 1.11 + "stability": "stable" 1.12 +}; 1.13 + 1.14 +const { Cc, Ci, Cu } = require("chrome"); 1.15 +const file = require("./io/file"); 1.16 +const prefs = require("./preferences/service"); 1.17 +const jpSelf = require("./self"); 1.18 +const timer = require("./timers"); 1.19 +const unload = require("./system/unload"); 1.20 +const { emit, on, off } = require("./event/core"); 1.21 +const { defer } = require('./core/promise'); 1.22 + 1.23 +const { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); 1.24 + 1.25 +const WRITE_PERIOD_PREF = "extensions.addon-sdk.simple-storage.writePeriod"; 1.26 +const WRITE_PERIOD_DEFAULT = 300000; // 5 minutes 1.27 + 1.28 +const QUOTA_PREF = "extensions.addon-sdk.simple-storage.quota"; 1.29 +const QUOTA_DEFAULT = 5242880; // 5 MiB 1.30 + 1.31 +const JETPACK_DIR_BASENAME = "jetpack"; 1.32 + 1.33 +Object.defineProperties(exports, { 1.34 + storage: { 1.35 + enumerable: true, 1.36 + get: function() { return manager.root; }, 1.37 + set: function(value) { manager.root = value; } 1.38 + }, 1.39 + quotaUsage: { 1.40 + get: function() { return manager.quotaUsage; } 1.41 + } 1.42 +}); 1.43 + 1.44 +function getHash(data) { 1.45 + let { promise, resolve } = defer(); 1.46 + 1.47 + let crypto = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); 1.48 + crypto.init(crypto.MD5); 1.49 + 1.50 + let listener = { 1.51 + onStartRequest: function() { }, 1.52 + 1.53 + onDataAvailable: function(request, context, inputStream, offset, count) { 1.54 + crypto.updateFromStream(inputStream, count); 1.55 + }, 1.56 + 1.57 + onStopRequest: function(request, context, status) { 1.58 + resolve(crypto.finish(false)); 1.59 + } 1.60 + }; 1.61 + 1.62 + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. 1.63 + createInstance(Ci.nsIScriptableUnicodeConverter); 1.64 + converter.charset = "UTF-8"; 1.65 + let stream = converter.convertToInputStream(data); 1.66 + let pump = Cc["@mozilla.org/network/input-stream-pump;1"]. 1.67 + createInstance(Ci.nsIInputStreamPump); 1.68 + pump.init(stream, -1, -1, 0, 0, true); 1.69 + pump.asyncRead(listener, null); 1.70 + 1.71 + return promise; 1.72 +} 1.73 + 1.74 +function writeData(filename, data) { 1.75 + let { promise, resolve, reject } = defer(); 1.76 + 1.77 + let stream = file.open(filename, "w"); 1.78 + try { 1.79 + stream.writeAsync(data, err => { 1.80 + if (err) 1.81 + reject(err); 1.82 + else 1.83 + resolve(); 1.84 + }); 1.85 + } 1.86 + catch (err) { 1.87 + // writeAsync closes the stream after it's done, so only close on error. 1.88 + stream.close(); 1.89 + reject(err); 1.90 + } 1.91 + 1.92 + return promise; 1.93 +} 1.94 + 1.95 +// A generic JSON store backed by a file on disk. This should be isolated 1.96 +// enough to move to its own module if need be... 1.97 +function JsonStore(options) { 1.98 + this.filename = options.filename; 1.99 + this.quota = options.quota; 1.100 + this.writePeriod = options.writePeriod; 1.101 + this.onOverQuota = options.onOverQuota; 1.102 + this.onWrite = options.onWrite; 1.103 + this.hash = null; 1.104 + unload.ensure(this); 1.105 + this.startTimer(); 1.106 +} 1.107 + 1.108 +JsonStore.prototype = { 1.109 + // The store's root. 1.110 + get root() { 1.111 + return this.isRootInited ? this._root : {}; 1.112 + }, 1.113 + 1.114 + // Performs some type checking. 1.115 + set root(val) { 1.116 + let types = ["array", "boolean", "null", "number", "object", "string"]; 1.117 + if (types.indexOf(typeof(val)) < 0) { 1.118 + throw new Error("storage must be one of the following types: " + 1.119 + types.join(", ")); 1.120 + } 1.121 + this._root = val; 1.122 + return val; 1.123 + }, 1.124 + 1.125 + // True if the root has ever been set (either via the root setter or by the 1.126 + // backing file's having been read). 1.127 + get isRootInited() { 1.128 + return this._root !== undefined; 1.129 + }, 1.130 + 1.131 + // Percentage of quota used, as a number [0, Inf). > 1 implies over quota. 1.132 + // Undefined if there is no quota. 1.133 + get quotaUsage() { 1.134 + return this.quota > 0 ? 1.135 + JSON.stringify(this.root).length / this.quota : 1.136 + undefined; 1.137 + }, 1.138 + 1.139 + startTimer: function JsonStore_startTimer() { 1.140 + timer.setTimeout(() => { 1.141 + this.write().then(this.startTimer.bind(this)); 1.142 + }, this.writePeriod); 1.143 + }, 1.144 + 1.145 + // Removes the backing file and all empty subdirectories. 1.146 + purge: function JsonStore_purge() { 1.147 + try { 1.148 + // This'll throw if the file doesn't exist. 1.149 + file.remove(this.filename); 1.150 + this.hash = null; 1.151 + let parentPath = this.filename; 1.152 + do { 1.153 + parentPath = file.dirname(parentPath); 1.154 + // This'll throw if the dir isn't empty. 1.155 + file.rmdir(parentPath); 1.156 + } while (file.basename(parentPath) !== JETPACK_DIR_BASENAME); 1.157 + } 1.158 + catch (err) {} 1.159 + }, 1.160 + 1.161 + // Initializes the root by reading the backing file. 1.162 + read: function JsonStore_read() { 1.163 + try { 1.164 + let str = file.read(this.filename); 1.165 + 1.166 + // Ideally we'd log the parse error with console.error(), but logged 1.167 + // errors cause tests to fail. Supporting "known" errors in the test 1.168 + // harness appears to be non-trivial. Maybe later. 1.169 + this.root = JSON.parse(str); 1.170 + let self = this; 1.171 + getHash(str).then(hash => this.hash = hash); 1.172 + } 1.173 + catch (err) { 1.174 + this.root = {}; 1.175 + this.hash = null; 1.176 + } 1.177 + }, 1.178 + 1.179 + // Cleans up on unload. If unloading because of uninstall, the store is 1.180 + // purged; otherwise it's written. 1.181 + unload: function JsonStore_unload(reason) { 1.182 + timer.clearTimeout(this.writeTimer); 1.183 + this.writeTimer = null; 1.184 + 1.185 + if (reason === "uninstall") 1.186 + this.purge(); 1.187 + else 1.188 + this.write(); 1.189 + }, 1.190 + 1.191 + // True if the root is an empty object. 1.192 + get _isEmpty() { 1.193 + if (this.root && typeof(this.root) === "object") { 1.194 + let empty = true; 1.195 + for (let key in this.root) { 1.196 + empty = false; 1.197 + break; 1.198 + } 1.199 + return empty; 1.200 + } 1.201 + return false; 1.202 + }, 1.203 + 1.204 + // Writes the root to the backing file, notifying write observers when 1.205 + // complete. If the store is over quota or if it's empty and the store has 1.206 + // never been written, nothing is written and write observers aren't notified. 1.207 + write: Task.async(function JsonStore_write() { 1.208 + // Don't write if the root is uninitialized or if the store is empty and the 1.209 + // backing file doesn't yet exist. 1.210 + if (!this.isRootInited || (this._isEmpty && !file.exists(this.filename))) 1.211 + return; 1.212 + 1.213 + let data = JSON.stringify(this.root); 1.214 + 1.215 + // If the store is over quota, don't write. The current under-quota state 1.216 + // should persist. 1.217 + if ((this.quota > 0) && (data.length > this.quota)) { 1.218 + this.onOverQuota(this); 1.219 + return; 1.220 + } 1.221 + 1.222 + // Hash the data to compare it to any previously written data 1.223 + let hash = yield getHash(data); 1.224 + 1.225 + if (hash == this.hash) 1.226 + return; 1.227 + 1.228 + // Finally, write. 1.229 + try { 1.230 + yield writeData(this.filename, data); 1.231 + 1.232 + this.hash = hash; 1.233 + if (this.onWrite) 1.234 + this.onWrite(this); 1.235 + } 1.236 + catch (err) { 1.237 + console.error("Error writing simple storage file: " + this.filename); 1.238 + console.error(err); 1.239 + } 1.240 + }) 1.241 +}; 1.242 + 1.243 + 1.244 +// This manages a JsonStore singleton and tailors its use to simple storage. 1.245 +// The root of the JsonStore is lazy-loaded: The backing file is only read the 1.246 +// first time the root's gotten. 1.247 +let manager = ({ 1.248 + jsonStore: null, 1.249 + 1.250 + // The filename of the store, based on the profile dir and extension ID. 1.251 + get filename() { 1.252 + let storeFile = Cc["@mozilla.org/file/directory_service;1"]. 1.253 + getService(Ci.nsIProperties). 1.254 + get("ProfD", Ci.nsIFile); 1.255 + storeFile.append(JETPACK_DIR_BASENAME); 1.256 + storeFile.append(jpSelf.id); 1.257 + storeFile.append("simple-storage"); 1.258 + file.mkpath(storeFile.path); 1.259 + storeFile.append("store.json"); 1.260 + return storeFile.path; 1.261 + }, 1.262 + 1.263 + get quotaUsage() { 1.264 + return this.jsonStore.quotaUsage; 1.265 + }, 1.266 + 1.267 + get root() { 1.268 + if (!this.jsonStore.isRootInited) 1.269 + this.jsonStore.read(); 1.270 + return this.jsonStore.root; 1.271 + }, 1.272 + 1.273 + set root(val) { 1.274 + return this.jsonStore.root = val; 1.275 + }, 1.276 + 1.277 + unload: function manager_unload() { 1.278 + off(this); 1.279 + }, 1.280 + 1.281 + new: function manager_constructor() { 1.282 + let manager = Object.create(this); 1.283 + unload.ensure(manager); 1.284 + 1.285 + manager.jsonStore = new JsonStore({ 1.286 + filename: manager.filename, 1.287 + writePeriod: prefs.get(WRITE_PERIOD_PREF, WRITE_PERIOD_DEFAULT), 1.288 + quota: prefs.get(QUOTA_PREF, QUOTA_DEFAULT), 1.289 + onOverQuota: emit.bind(null, exports, "OverQuota") 1.290 + }); 1.291 + 1.292 + return manager; 1.293 + } 1.294 +}).new(); 1.295 + 1.296 +exports.on = on.bind(null, exports); 1.297 +exports.removeListener = function(type, listener) { 1.298 + off(exports, type, listener); 1.299 +};