1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
|
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* Java Script module that helps consumers store data directly
* to cloud storage provider download folders.
*
* Takes cloud storage providers metadata as JSON input on Mac, Linux and Windows.
*
* Handles scan, prompt response save and exposes preferred storage provider.
*/
"use strict";
var EXPORTED_SYMBOLS = ["CloudStorage"];
const { AppConstants } = ChromeUtils.import(
"resource://gre/modules/AppConstants.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
ChromeUtils.defineModuleGetter(
this,
"Downloads",
"resource://gre/modules/Downloads.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"FileUtils",
"resource://gre/modules/FileUtils.jsm"
);
ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
const CLOUD_SERVICES_PREF = "cloud.services.";
const CLOUD_PROVIDERS_URI = "resource://cloudstorage/providers.json";
/**
* Provider metadata JSON is loaded from resource://cloudstorage/providers.json
* Sample providers.json format
*
* {
* "Dropbox": {
* "displayName": "Dropbox",
* "relativeDownloadPath": ["homeDir", "Dropbox"],
* "relativeDiscoveryPath": {
* "linux": ["homeDir", ".dropbox", "info.json"],
* "macosx": ["homeDir", ".dropbox", "info.json"],
* "win": ["LocalAppData", "Dropbox", "info.json"]
* },
* "typeSpecificData": {
* "default": "Downloads",
* "screenshot": "Screenshots"
* }
* }
*
* Providers JSON is flat list of providers metdata with property as key in format @Provider
*
* @Provider - Unique cloud provider key, possible values: "Dropbox", "GDrive"
*
* @displayName - cloud storage name displayed in the prompt.
*
* @relativeDownloadPath - download path on user desktop for a cloud storage provider.
* By default downloadPath is a concatenation of home dir and name of dropbox folder.
* Example value: ["homeDir", "Dropbox"]
*
* @relativeDiscoveryPath - Lists discoveryPath by platform. Provider is not supported on a platform
* if its value doesn't exist in relativeDiscoveryPath. relativeDiscoveryPath by platform is stored
* as an array ofsubdirectories, which when concatenated, forms discovery path.
* During scan discoveryPath is checked for the existence of cloud storage provider on user desktop.
*
* @typeSpecificData - provides folder name for a cloud storage depending
* on type of data downloaded. Default folder is 'Downloads'. Other options are
* 'screenshot' depending on provider support.
*/
/**
*
* Internal cloud services prefs
*
* cloud.services.api.enabled - set to true to initialize and use Cloud Storage module
*
* cloud.services.storage.key - set to string with preferred provider key
*
* cloud.services.lastPrompt - set to time when last prompt was shown
*
* cloud.services.interval.prompt - set to time interval in days after which prompt should be shown
*
* cloud.services.rejected.key - set to string with comma separated provider keys rejected
* by user when prompted to opt-in
*
* browser.download.folderList - set to int and indicates the location users wish to save downloaded files to.
* 0 - The desktop is the default download location.
* 1 - The system's downloads folder is the default download location.
* 2 - The default download location is elsewhere as specified in
* browser.download.dir.
* 3 - The default download location is elsewhere as specified by
* cloud storage API getDownloadFolder
*
* browser.download.dir - local file handle
* A local folder user may have selected for downloaded files to be
* saved. This folder is enabled when folderList equals 2.
*/
/**
* The external API exported by this module.
*/
var CloudStorage = {
/**
* Init method to initialize providers metadata
*/
async init() {
let isInitialized = null;
try {
// Invoke internal method asynchronously to read and
// parse providers metadata from JSON
isInitialized = await CloudStorageInternal.initProviders();
} catch (err) {
Cu.reportError(err);
}
return isInitialized;
},
/**
* Returns information to allow the consumer to decide whether showing
* a doorhanger prompt is appropriate. If a preferred provider is set
* on desktop, user is not prompted again and method returns null.
*
* @return {Promise} which resolves to an object with property name
* as 'key' and 'value'.
* 'key' property is provider key such as 'Dropbox', 'GDrive'.
* 'value' property contains metadata for respective provider.
* Resolves null if it's not appropriate to prompt.
*/
promisePromptInfo() {
return CloudStorageInternal.promisePromptInfo();
},
/**
* Save user response from doorhanger prompt.
* If user confirms and checks 'always remember', update prefs
* cloud.services.storage.key and browser.download.folderList to pick
* download location from cloud storage API
* If user denies, save provider as rejected in cloud.services.rejected.key
*
* @param key
* cloud storage provider key from provider metadata
* @param remember
* bool value indicating whether user has asked to always remember
* the settings
* @param selected
* bool value by default set to false indicating if user has selected
* to save downloaded file with cloud provider
*/
savePromptResponse(key, remember, selected = false) {
Services.prefs.setIntPref(
CLOUD_SERVICES_PREF + "lastprompt",
Math.floor(Date.now() / 1000)
);
if (remember) {
if (selected) {
CloudStorageInternal.setCloudStoragePref(key);
} else {
// Store provider as rejected by setting cloud.services.rejected.key
// and not use in re-prompt
CloudStorageInternal.handleRejected(key);
}
}
},
/**
* Retrieve download folder of an opted-in storage provider
* by type specific data
* @param typeSpecificData
* type of data downloaded, options are 'default', 'screenshot'
* @return {Promise} which resolves to full path to provider download folder
*/
getDownloadFolder(typeSpecificData) {
return CloudStorageInternal.getDownloadFolder(typeSpecificData);
},
/**
* Get key of provider opted-in by user to store downloaded files
*
* @return {String}
* Storage provider key from provider metadata. Return empty string
* if user has not selected a preferred provider.
*/
getPreferredProvider() {
return CloudStorageInternal.preferredProviderKey;
},
/**
* Get metadata of provider opted-in by user to store downloaded files.
* Return preferred provider metadata without scanning by doing simple lookup
* inside storage providers metadata using preferred provider key
*
* @return {Object}
* Object with preferred provider metadata. Return null
* if user has not selected a preferred provider.
*/
getPreferredProviderMetaData() {
return CloudStorageInternal.getPreferredProviderMetaData();
},
/**
* Get display name of a provider actively in use to store downloaded files
*
* @return {String}
* String with provider display name. Returns null if a provider
* is not in use.
*/
getProviderIfInUse() {
return CloudStorageInternal.getProviderIfInUse();
},
/**
* Get providers found on user desktop. Used for unit tests
*
* @return {Promise}
* @resolves
* Map object with entries key set to storage provider key and values set to
* storage provider metadata
*/
getStorageProviders() {
return CloudStorageInternal.getStorageProviders();
},
};
/**
* The internal API for the CloudStorage module.
*/
var CloudStorageInternal = {
/**
* promiseInit saves returned init method promise and is
* used to wait for initialization to complete.
*/
promiseInit: null,
/**
* Internal property having storage providers data
*/
providersMetaData: null,
async _downloadJSON(uri) {
let json = null;
try {
let response = await fetch(uri);
if (response.ok) {
json = await response.json();
}
} catch (e) {
Cu.reportError("Fetching " + uri + " results in error: " + e);
}
return json;
},
/**
* Reset 'browser.download.folderList' cloud storage value '3' back
* to '2' or '1' depending on custom path or system default Downloads path
* in pref 'browser.download.dir'.
*/
async resetFolderListPref() {
let folderListValue = Services.prefs.getIntPref(
"browser.download.folderList",
0
);
if (folderListValue !== 3) {
return;
}
let downloadDirPath = null;
try {
let file = Services.prefs.getComplexValue(
"browser.download.dir",
Ci.nsIFile
);
downloadDirPath = file.path;
} catch (e) {}
if (
!downloadDirPath ||
downloadDirPath === (await Downloads.getSystemDownloadsDirectory())
) {
// if downloadDirPath is the Downloads folder path or unspecified
folderListValue = 1;
} else if (
downloadDirPath === Services.dirsvc.get("Desk", Ci.nsIFile).path
) {
// if downloadDirPath is the Desktop path
folderListValue = 0;
} else {
// otherwise
folderListValue = 2;
}
Services.prefs.setIntPref("browser.download.folderList", folderListValue);
},
/**
* Loads storage providers metadata asynchronously from providers.json.
*
* @returns {Promise} with resolved boolean value true if providers
* metadata is successfully initialized
*/
async initProviders() {
// Cloud Storage API should continue initialization and load providers metadata
// only if a consumer add-on using API sets pref 'cloud.services.api.enabled' to true
// If API is not enabled, check and reset cloud storage value in folderList pref.
if (!this.isAPIEnabled) {
this.resetFolderListPref().catch(err => {
Cu.reportError("CloudStorage: Failed to reset folderList pref " + err);
});
return false;
}
let response = await this._downloadJSON(CLOUD_PROVIDERS_URI);
this.providersMetaData = await this._parseProvidersJSON(response);
let providersCount = Object.keys(this.providersMetaData).length;
if (providersCount > 0) {
// Array of boolean results for each provider handled for custom downloadpath
let handledProviders = await this.initDownloadPathIfProvidersExist();
if (handledProviders.length === providersCount) {
return true;
}
}
return false;
},
/**
* Load parsed metadata inside providers object
*/
_parseProvidersJSON(providers) {
if (!providers) {
return {};
}
// Use relativeDiscoveryPath to filter providers object by platform.
// DownloadPath and discoveryPath are stored as
// array of subdirectories inside providers.json
// Update providers object discoveryPath and downloadPath
// property values by concatenating subdirectories and forming platform
// specific directory path
Object.getOwnPropertyNames(providers).forEach(key => {
if (
providers[key].relativeDiscoveryPath.hasOwnProperty(
AppConstants.platform
)
) {
providers[key].discoveryPath = this._concatPath(
providers[key].relativeDiscoveryPath[AppConstants.platform]
);
providers[key].downloadPath = this._concatPath(
providers[key].relativeDownloadPath
);
} else {
// delete key not supported on AppConstants.platform
delete providers[key];
}
});
return providers;
},
/**
* Concatenate subdir value inside array to form
* platform specific directory path
*
* @param arrDirs
* String Array containing sub directories name
* @returns Path of type String
*/
_concatPath(arrDirs) {
let dirPath = "";
for (let subDir of arrDirs) {
switch (subDir) {
case "homeDir":
subDir = OS.Constants.Path.homeDir ? OS.Constants.Path.homeDir : "";
break;
case "LocalAppData":
if (OS.Constants.Win) {
let nsIFileLocal = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
subDir = nsIFileLocal && nsIFileLocal.path ? nsIFileLocal.path : "";
} else {
subDir = "";
}
break;
}
dirPath = OS.Path.join(dirPath, subDir);
}
return dirPath;
},
/**
* Check for custom download paths and override providers metadata
* downloadPath property
*
* For dropbox open config file ~/.dropbox/info.json
* and override downloadPath with path found
* See https://www.dropbox.com/en/help/desktop-web/find-folder-paths
*
* For all other providers we are using downloadpath from providers.json
*
* @returns {Promise} with array boolean values for respective provider. Value is true if a
* provider exist on user desktop and its downloadPath is updated. Promise returns with
* resolved array value when all providers in metadata are handled.
*/
initDownloadPathIfProvidersExist() {
let providerKeys = Object.keys(this.providersMetaData);
let promises = providerKeys.map(key => {
return key === "Dropbox"
? this._initDropbox(key)
: Promise.resolve(false);
});
return Promise.all(promises);
},
/**
* Read Dropbox info.json and override providers metadata
* downloadPath property
*
* @return {Promise}
* @resolves
* false if dropbox provider is not found. Returns true if dropbox service exist
* on user desktop and downloadPath in providermetadata is updated with
* value read from config file info.json
*/
async _initDropbox(key) {
// Check if Dropbox provider exist on desktop before continuing
if (
!(await this._checkIfAssetExists(
this.providersMetaData[key].discoveryPath
))
) {
return false;
}
// Check in cloud.services.rejected.key if Dropbox is previously rejected before continuing
let rejectedKeys = this.cloudStorageRejectedKeys.split(",");
if (rejectedKeys.includes(key)) {
return false;
}
let file = null;
try {
file = new FileUtils.File(this.providersMetaData[key].discoveryPath);
} catch (ex) {
return false;
}
let data = await this._downloadJSON(Services.io.newFileURI(file).spec);
if (!data) {
return false;
}
let path = data && data.personal && data.personal.path;
if (!path) {
return false;
}
let isUsable = await this._isUsableDirectory(path);
if (isUsable) {
this.providersMetaData.Dropbox.downloadPath = path;
}
return isUsable;
},
/**
* Determines if a given directory is valid and can be used to download files
*
* @param full absolute path to the directory
*
* @return {Promise} which resolves true if we can use the directory, false otherwise.
*/
async _isUsableDirectory(path) {
let isUsable = false;
try {
let info = await OS.File.stat(path);
isUsable = info.isDir;
} catch (e) {
// Directory doesn't exist, so isUsable will still be false
}
return isUsable;
},
/**
* Retrieve download folder of preferred provider by type specific data
*
* @param dataType
* type of data downloaded, options are 'default', 'screenshot'
* default value is 'default'
* @return {Promise} which resolves to full path to download folder
* Resolves null if a valid download folder is not found.
*/
async getDownloadFolder(dataType = "default") {
// Wait for cloudstorage to initialize if providers metadata is not available
if (!this.providersMetaData) {
let isInitialized = await this.promiseInit;
if (!isInitialized && !this.providersMetaData) {
Cu.reportError(
"CloudStorage: Failed to initialize and retrieve download folder "
);
return null;
}
}
let key = this.preferredProviderKey;
if (!key || !this.providersMetaData.hasOwnProperty(key)) {
return null;
}
let provider = this.providersMetaData[key];
if (!provider.typeSpecificData[dataType]) {
return null;
}
let downloadDirPath = OS.Path.join(
provider.downloadPath,
provider.typeSpecificData[dataType]
);
if (!(await this._isUsableDirectory(downloadDirPath))) {
return null;
}
return downloadDirPath;
},
/**
* Return scanned provider info used by consumer inside doorhanger prompt.
* @return {Promise}
* which resolves to an object with property 'key' as found provider and
* property 'value' as provider metadata.
* Resolves null if no provider info is returned.
*/
async promisePromptInfo() {
// Check if user has not previously opted-in for preferred provider download folder
// and if time elapsed since last prompt shown has exceeded maximum allowed interval
// in pref cloud.services.interval.prompt before continuing to scan for providers
if (!this.preferredProviderKey && this.shouldPrompt()) {
return this.scan();
}
return Promise.resolve(null);
},
/**
* Check if its time to prompt by reading lastprompt service pref.
* Return true if pref doesn't exist or last prompt time is
* more than prompt interval
*/
shouldPrompt() {
let lastPrompt = this.lastPromptTime;
let now = Math.floor(Date.now() / 1000);
let interval = now - lastPrompt;
// Convert prompt interval to seconds
let maxAllow = this.promptInterval * 24 * 60 * 60;
return interval >= maxAllow;
},
/**
* Scans for local storage providers available on user desktop
*
* providers list is read in order as specified in providers.json.
* If a user has multiple cloud storage providers on desktop, return the first
* provider after filtering the rejected keys
*
* @return {Promise}
* which resolves to an object providerInfo with found provider key and value
* as provider metadata. Resolves null if no valid provider found
*/
async scan() {
let providers = await this.getStorageProviders();
if (!providers.size) {
// No storage services installed on user desktop
return null;
}
// Filter the rejected providers in cloud.services.rejected.key
// from the providers map object
let rejectedKeys = this.cloudStorageRejectedKeys.split(",");
for (let rejectedKey of rejectedKeys) {
providers.delete(rejectedKey);
}
// Pick first storage provider from providers
let provider = providers.entries().next().value;
if (provider) {
return { key: provider[0], value: provider[1] };
}
return null;
},
/**
* Checks if the asset with input path exist on
* file system
* @return {Promise}
* @resolves
* boolean value of file existence check
*/
_checkIfAssetExists(path) {
return OS.File.exists(path).catch(err => {
Cu.reportError(`Couldn't check existance of ${path}`, err);
return false;
});
},
/**
* get access to all local storage providers available on user desktop
*
* @return {Promise}
* @resolves
* Map object with entries key set to storage provider key and values set to
* storage provider metadata
*/
async getStorageProviders() {
let providers = Object.entries(this.providersMetaData || {});
// Array of promises with boolean value exist for respective storage.
let promises = providers.map(([, provider]) =>
this._checkIfAssetExists(provider.discoveryPath)
);
let results = await Promise.all(promises);
// Filter providers array to remove provider with discoveryPath asset exist resolved value false
providers = providers.filter((_, idx) => results[idx]);
return new Map(providers);
},
/**
* Save the rejected provider in cloud.services.rejected.key. Pref
* stores rejected keys value as comma separated string.
*
* @param key
* Provider key to be saved in cloud.services.rejected.key pref
*/
handleRejected(key) {
let rejected = this.cloudStorageRejectedKeys;
if (!rejected) {
Services.prefs.setCharPref(CLOUD_SERVICES_PREF + "rejected.key", key);
} else {
// Pref exists with previous rejected keys, append
// key at the end and update pref
let keys = rejected.split(",");
if (key) {
keys.push(key);
}
Services.prefs.setCharPref(
CLOUD_SERVICES_PREF + "rejected.key",
keys.join(",")
);
}
},
/**
*
* Sets pref cloud.services.storage.key. It updates download browser.download.folderList
* value to 3 indicating download location is stored elsewhere, as specified by
* cloud storage API getDownloadFolder
*
* @param key
* cloud storage provider key from provider metadata
*/
setCloudStoragePref(key) {
Services.prefs.setCharPref(CLOUD_SERVICES_PREF + "storage.key", key);
Services.prefs.setIntPref("browser.download.folderList", 3);
},
/**
* get access to preferred provider metadata by using preferred provider key
*
* @return {Object}
* Object with preferred provider metadata. Returns null if preferred provider is not set
*/
getPreferredProviderMetaData() {
// Use preferred provider key to retrieve metadata from ProvidersMetaData
return this.providersMetaData.hasOwnProperty(this.preferredProviderKey)
? this.providersMetaData[this.preferredProviderKey]
: null;
},
/**
* Get provider display name if cloud storage API is used by an add-on
* and user has set preferred provider and a valid download directory
* path exists on user desktop.
*
* @return {String}
* String with preferred provider display name. Returns null if provider is not in use.
*/
async getProviderIfInUse() {
// Check if consumer add-on is present and user has set preferred provider key
// and a valid download path exist on user desktop
if (
this.isAPIEnabled &&
this.preferredProviderKey &&
(await this.getDownloadFolder())
) {
let provider = this.getPreferredProviderMetaData();
return provider.displayName || null;
}
return null;
},
};
/**
* Provider key retrieved from service pref cloud.services.storage.key
*/
XPCOMUtils.defineLazyPreferenceGetter(
CloudStorageInternal,
"preferredProviderKey",
CLOUD_SERVICES_PREF + "storage.key",
""
);
/**
* Provider keys rejected by user for default download
*/
XPCOMUtils.defineLazyPreferenceGetter(
CloudStorageInternal,
"cloudStorageRejectedKeys",
CLOUD_SERVICES_PREF + "rejected.key",
""
);
/**
* Lastprompt time in seconds, by default set to 0
*/
XPCOMUtils.defineLazyPreferenceGetter(
CloudStorageInternal,
"lastPromptTime",
CLOUD_SERVICES_PREF + "lastprompt",
0 /* 0 second */
);
/**
* show prompt interval in days, by default set to 0
*/
XPCOMUtils.defineLazyPreferenceGetter(
CloudStorageInternal,
"promptInterval",
CLOUD_SERVICES_PREF + "interval.prompt",
0 /* 0 days */
);
/**
* generic pref that shows if cloud storage API is in use, by default set to false.
* Re-run CloudStorage init evertytime pref is set.
*/
XPCOMUtils.defineLazyPreferenceGetter(
CloudStorageInternal,
"isAPIEnabled",
CLOUD_SERVICES_PREF + "api.enabled",
false,
() => CloudStorage.init()
);
CloudStorageInternal.promiseInit = CloudStorage.init();
|