|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 "use strict"; |
|
6 |
|
7 module.metadata = { |
|
8 "stability": "stable" |
|
9 }; |
|
10 |
|
11 const { Cc, Ci, Cu } = require("chrome"); |
|
12 const file = require("./io/file"); |
|
13 const prefs = require("./preferences/service"); |
|
14 const jpSelf = require("./self"); |
|
15 const timer = require("./timers"); |
|
16 const unload = require("./system/unload"); |
|
17 const { emit, on, off } = require("./event/core"); |
|
18 const { defer } = require('./core/promise'); |
|
19 |
|
20 const { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); |
|
21 |
|
22 const WRITE_PERIOD_PREF = "extensions.addon-sdk.simple-storage.writePeriod"; |
|
23 const WRITE_PERIOD_DEFAULT = 300000; // 5 minutes |
|
24 |
|
25 const QUOTA_PREF = "extensions.addon-sdk.simple-storage.quota"; |
|
26 const QUOTA_DEFAULT = 5242880; // 5 MiB |
|
27 |
|
28 const JETPACK_DIR_BASENAME = "jetpack"; |
|
29 |
|
30 Object.defineProperties(exports, { |
|
31 storage: { |
|
32 enumerable: true, |
|
33 get: function() { return manager.root; }, |
|
34 set: function(value) { manager.root = value; } |
|
35 }, |
|
36 quotaUsage: { |
|
37 get: function() { return manager.quotaUsage; } |
|
38 } |
|
39 }); |
|
40 |
|
41 function getHash(data) { |
|
42 let { promise, resolve } = defer(); |
|
43 |
|
44 let crypto = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); |
|
45 crypto.init(crypto.MD5); |
|
46 |
|
47 let listener = { |
|
48 onStartRequest: function() { }, |
|
49 |
|
50 onDataAvailable: function(request, context, inputStream, offset, count) { |
|
51 crypto.updateFromStream(inputStream, count); |
|
52 }, |
|
53 |
|
54 onStopRequest: function(request, context, status) { |
|
55 resolve(crypto.finish(false)); |
|
56 } |
|
57 }; |
|
58 |
|
59 let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. |
|
60 createInstance(Ci.nsIScriptableUnicodeConverter); |
|
61 converter.charset = "UTF-8"; |
|
62 let stream = converter.convertToInputStream(data); |
|
63 let pump = Cc["@mozilla.org/network/input-stream-pump;1"]. |
|
64 createInstance(Ci.nsIInputStreamPump); |
|
65 pump.init(stream, -1, -1, 0, 0, true); |
|
66 pump.asyncRead(listener, null); |
|
67 |
|
68 return promise; |
|
69 } |
|
70 |
|
71 function writeData(filename, data) { |
|
72 let { promise, resolve, reject } = defer(); |
|
73 |
|
74 let stream = file.open(filename, "w"); |
|
75 try { |
|
76 stream.writeAsync(data, err => { |
|
77 if (err) |
|
78 reject(err); |
|
79 else |
|
80 resolve(); |
|
81 }); |
|
82 } |
|
83 catch (err) { |
|
84 // writeAsync closes the stream after it's done, so only close on error. |
|
85 stream.close(); |
|
86 reject(err); |
|
87 } |
|
88 |
|
89 return promise; |
|
90 } |
|
91 |
|
92 // A generic JSON store backed by a file on disk. This should be isolated |
|
93 // enough to move to its own module if need be... |
|
94 function JsonStore(options) { |
|
95 this.filename = options.filename; |
|
96 this.quota = options.quota; |
|
97 this.writePeriod = options.writePeriod; |
|
98 this.onOverQuota = options.onOverQuota; |
|
99 this.onWrite = options.onWrite; |
|
100 this.hash = null; |
|
101 unload.ensure(this); |
|
102 this.startTimer(); |
|
103 } |
|
104 |
|
105 JsonStore.prototype = { |
|
106 // The store's root. |
|
107 get root() { |
|
108 return this.isRootInited ? this._root : {}; |
|
109 }, |
|
110 |
|
111 // Performs some type checking. |
|
112 set root(val) { |
|
113 let types = ["array", "boolean", "null", "number", "object", "string"]; |
|
114 if (types.indexOf(typeof(val)) < 0) { |
|
115 throw new Error("storage must be one of the following types: " + |
|
116 types.join(", ")); |
|
117 } |
|
118 this._root = val; |
|
119 return val; |
|
120 }, |
|
121 |
|
122 // True if the root has ever been set (either via the root setter or by the |
|
123 // backing file's having been read). |
|
124 get isRootInited() { |
|
125 return this._root !== undefined; |
|
126 }, |
|
127 |
|
128 // Percentage of quota used, as a number [0, Inf). > 1 implies over quota. |
|
129 // Undefined if there is no quota. |
|
130 get quotaUsage() { |
|
131 return this.quota > 0 ? |
|
132 JSON.stringify(this.root).length / this.quota : |
|
133 undefined; |
|
134 }, |
|
135 |
|
136 startTimer: function JsonStore_startTimer() { |
|
137 timer.setTimeout(() => { |
|
138 this.write().then(this.startTimer.bind(this)); |
|
139 }, this.writePeriod); |
|
140 }, |
|
141 |
|
142 // Removes the backing file and all empty subdirectories. |
|
143 purge: function JsonStore_purge() { |
|
144 try { |
|
145 // This'll throw if the file doesn't exist. |
|
146 file.remove(this.filename); |
|
147 this.hash = null; |
|
148 let parentPath = this.filename; |
|
149 do { |
|
150 parentPath = file.dirname(parentPath); |
|
151 // This'll throw if the dir isn't empty. |
|
152 file.rmdir(parentPath); |
|
153 } while (file.basename(parentPath) !== JETPACK_DIR_BASENAME); |
|
154 } |
|
155 catch (err) {} |
|
156 }, |
|
157 |
|
158 // Initializes the root by reading the backing file. |
|
159 read: function JsonStore_read() { |
|
160 try { |
|
161 let str = file.read(this.filename); |
|
162 |
|
163 // Ideally we'd log the parse error with console.error(), but logged |
|
164 // errors cause tests to fail. Supporting "known" errors in the test |
|
165 // harness appears to be non-trivial. Maybe later. |
|
166 this.root = JSON.parse(str); |
|
167 let self = this; |
|
168 getHash(str).then(hash => this.hash = hash); |
|
169 } |
|
170 catch (err) { |
|
171 this.root = {}; |
|
172 this.hash = null; |
|
173 } |
|
174 }, |
|
175 |
|
176 // Cleans up on unload. If unloading because of uninstall, the store is |
|
177 // purged; otherwise it's written. |
|
178 unload: function JsonStore_unload(reason) { |
|
179 timer.clearTimeout(this.writeTimer); |
|
180 this.writeTimer = null; |
|
181 |
|
182 if (reason === "uninstall") |
|
183 this.purge(); |
|
184 else |
|
185 this.write(); |
|
186 }, |
|
187 |
|
188 // True if the root is an empty object. |
|
189 get _isEmpty() { |
|
190 if (this.root && typeof(this.root) === "object") { |
|
191 let empty = true; |
|
192 for (let key in this.root) { |
|
193 empty = false; |
|
194 break; |
|
195 } |
|
196 return empty; |
|
197 } |
|
198 return false; |
|
199 }, |
|
200 |
|
201 // Writes the root to the backing file, notifying write observers when |
|
202 // complete. If the store is over quota or if it's empty and the store has |
|
203 // never been written, nothing is written and write observers aren't notified. |
|
204 write: Task.async(function JsonStore_write() { |
|
205 // Don't write if the root is uninitialized or if the store is empty and the |
|
206 // backing file doesn't yet exist. |
|
207 if (!this.isRootInited || (this._isEmpty && !file.exists(this.filename))) |
|
208 return; |
|
209 |
|
210 let data = JSON.stringify(this.root); |
|
211 |
|
212 // If the store is over quota, don't write. The current under-quota state |
|
213 // should persist. |
|
214 if ((this.quota > 0) && (data.length > this.quota)) { |
|
215 this.onOverQuota(this); |
|
216 return; |
|
217 } |
|
218 |
|
219 // Hash the data to compare it to any previously written data |
|
220 let hash = yield getHash(data); |
|
221 |
|
222 if (hash == this.hash) |
|
223 return; |
|
224 |
|
225 // Finally, write. |
|
226 try { |
|
227 yield writeData(this.filename, data); |
|
228 |
|
229 this.hash = hash; |
|
230 if (this.onWrite) |
|
231 this.onWrite(this); |
|
232 } |
|
233 catch (err) { |
|
234 console.error("Error writing simple storage file: " + this.filename); |
|
235 console.error(err); |
|
236 } |
|
237 }) |
|
238 }; |
|
239 |
|
240 |
|
241 // This manages a JsonStore singleton and tailors its use to simple storage. |
|
242 // The root of the JsonStore is lazy-loaded: The backing file is only read the |
|
243 // first time the root's gotten. |
|
244 let manager = ({ |
|
245 jsonStore: null, |
|
246 |
|
247 // The filename of the store, based on the profile dir and extension ID. |
|
248 get filename() { |
|
249 let storeFile = Cc["@mozilla.org/file/directory_service;1"]. |
|
250 getService(Ci.nsIProperties). |
|
251 get("ProfD", Ci.nsIFile); |
|
252 storeFile.append(JETPACK_DIR_BASENAME); |
|
253 storeFile.append(jpSelf.id); |
|
254 storeFile.append("simple-storage"); |
|
255 file.mkpath(storeFile.path); |
|
256 storeFile.append("store.json"); |
|
257 return storeFile.path; |
|
258 }, |
|
259 |
|
260 get quotaUsage() { |
|
261 return this.jsonStore.quotaUsage; |
|
262 }, |
|
263 |
|
264 get root() { |
|
265 if (!this.jsonStore.isRootInited) |
|
266 this.jsonStore.read(); |
|
267 return this.jsonStore.root; |
|
268 }, |
|
269 |
|
270 set root(val) { |
|
271 return this.jsonStore.root = val; |
|
272 }, |
|
273 |
|
274 unload: function manager_unload() { |
|
275 off(this); |
|
276 }, |
|
277 |
|
278 new: function manager_constructor() { |
|
279 let manager = Object.create(this); |
|
280 unload.ensure(manager); |
|
281 |
|
282 manager.jsonStore = new JsonStore({ |
|
283 filename: manager.filename, |
|
284 writePeriod: prefs.get(WRITE_PERIOD_PREF, WRITE_PERIOD_DEFAULT), |
|
285 quota: prefs.get(QUOTA_PREF, QUOTA_DEFAULT), |
|
286 onOverQuota: emit.bind(null, exports, "OverQuota") |
|
287 }); |
|
288 |
|
289 return manager; |
|
290 } |
|
291 }).new(); |
|
292 |
|
293 exports.on = on.bind(null, exports); |
|
294 exports.removeListener = function(type, listener) { |
|
295 off(exports, type, listener); |
|
296 }; |