Bug 1192433: Part 2 - [webext] Allow loading and querying all available locales. r=billm

This commit is contained in:
Kris Maglione 2015-10-30 12:09:55 +01:00
parent 0b70cfbcfd
commit bc17521a3c

View File

@ -33,8 +33,12 @@ XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm"); "resource://gre/modules/NetUtil.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm"); "resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"); "resource://gre/modules/PrivateBrowsingUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/ExtensionManagement.jsm"); Cu.import("resource://gre/modules/ExtensionManagement.jsm");
@ -336,22 +340,48 @@ this.ExtensionData = function(rootURI)
this.rootURI = rootURI; this.rootURI = rootURI;
this.manifest = null; this.manifest = null;
this.localeMessages = null; this.id = null;
// Map(locale-name -> message-map)
// Contains a key for each loaded locale, each of which is a
// JSON-compatible object with a property for each message
// in that locale.
this.localeMessages = new Map();
this.selectedLocale = null; this.selectedLocale = null;
this._promiseLocales = null;
this.errors = []; this.errors = [];
} }
ExtensionData.prototype = { ExtensionData.prototype = {
get logger() {
let id = this.id || "<unknown>";
return Log.repository.getLogger(LOGGER_ID_BASE + id);
},
// Report an error about the extension's manifest file.
manifestError(message) {
this.packagingError(`Reading manifest: ${message}`);
},
// Report an error about the extension's general packaging.
packagingError(message) {
this.errors.push(message);
this.logger.error(`Loading extension '${this.id}': ${message}`);
},
// https://developer.chrome.com/extensions/i18n // https://developer.chrome.com/extensions/i18n
localizeMessage(message, substitutions) { localizeMessage(message, substitutions, locale = this.selectedLocale) {
if (message in this.localeMessages) { let messages = {};
let str = this.localeMessages[message].message; if (this.localeMessages.has(locale)) {
messages = this.localeMessages.get(locale);
}
if (message in messages) {
let str = messages[message].message;
if (!substitutions) { if (!substitutions) {
substitutions = []; substitutions = [];
} } else if (!Array.isArray(substitutions)) {
if (!Array.isArray(substitutions)) {
substitutions = [substitutions]; substitutions = [substitutions];
} }
@ -364,7 +394,7 @@ ExtensionData.prototype = {
if (name.length == 1 && name[0] >= '1' && name[0] <= '9') { if (name.length == 1 && name[0] >= '1' && name[0] <= '9') {
return substitutions[parseInt(name) - 1]; return substitutions[parseInt(name) - 1];
} else { } else {
let content = this.localeMessages[message].placeholders[name].content; let content = messages[message].placeholders[name].content;
if (content[0] == '$') { if (content[0] == '$') {
return replacer(matched, content[1]); return replacer(matched, content[1]);
} else { } else {
@ -379,7 +409,13 @@ ExtensionData.prototype = {
// Check for certain pre-defined messages. // Check for certain pre-defined messages.
if (message == "@@extension_id") { if (message == "@@extension_id") {
return this.id; if ("uuid" in this) {
// Per Chrome, this isn't available before an ID is guaranteed
// to have been assigned, namely, in manifest files.
// This should only be present in instances of the |Extension|
// subclass.
return this.uuid;
}
} else if (message == "@@ui_locale") { } else if (message == "@@ui_locale") {
return Locale.getLocale(); return Locale.getLocale();
} else if (message == "@@bidi_dir") { } else if (message == "@@bidi_dir") {
@ -390,28 +426,115 @@ ExtensionData.prototype = {
return "??"; return "??";
}, },
localize(str) { // Localize a string, replacing all |__MSG_(.*)__| tokens with the
// matching string from the current locale, as determined by
// |this.selectedLocale|.
//
// This may not be called before calling either |initLocale| or
// |initAllLocales|.
localize(str, locale = this.selectedLocale) {
if (!str) { if (!str) {
return str; return str;
} }
if (str.startsWith("__MSG_") && str.endsWith("__")) { return str.replace(/__MSG_([A-Za-z0-9@_]+?)__/g, (matched, message) => {
let message = str.substring("__MSG_".length, str.length - "__".length); return this.localizeMessage(message, [], locale);
return this.localizeMessage(message); });
}
return str;
}, },
readJSON(uri) { // If a "default_locale" is specified in that manifest, returns it
// as a Gecko-compatible locale string. Otherwise, returns null.
get defaultLocale() {
if ("default_locale" in this.manifest) {
return this.normalizeLocaleCode(this.manifest.default_locale);
}
return null;
},
// Normalizes a Chrome-compatible locale code to the appropriate
// Gecko-compatible variant. Currently, this means simply
// replacing underscores with hyphens.
normalizeLocaleCode(locale) {
return String.replace(locale, /_/g, "-");
},
readDirectory: Task.async(function* (path) {
if (this.rootURI instanceof Ci.nsIFileURL) {
let uri = NetUtil.newURI(this.rootURI.resolve("./" + path));
let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path;
let iter = new OS.File.DirectoryIterator(fullPath);
let results = [];
try {
yield iter.forEach(entry => {
results.push(entry);
});
} catch (e) {}
iter.close();
// Always return a list, even if the directory does not exist (or is
// not a directory) for symmetry with the ZipReader behavior.
return results;
}
if (!(this.rootURI instanceof Ci.nsIJARURI &&
this.rootURI.JARFile instanceof Ci.nsIFileURL)) {
throw Error("Invalid extension root URL");
}
// FIXME: We need a way to do this without main thread IO.
let file = this.rootURI.JARFile.file;
let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(Ci.nsIZipReader);
try {
zipReader.open(file);
let results = [];
// Normalize the directory path.
path = path.replace(/\/\/+/g, "/").replace(/^\/|\/$/g, "") + "/";
// Escape pattern metacharacters.
let pattern = path.replace(/[[\]()?*~|$\\]/g, "\\$&");
let enumerator = zipReader.findEntries(pattern + "*");
while (enumerator.hasMore()) {
let name = enumerator.getNext();
if (!name.startsWith(path)) {
throw new Error("Unexpected ZipReader entry");
}
// The enumerator returns the full path of all entries.
// Trim off the leading path, and filter out entries from
// subdirectories.
name = name.slice(path.length);
if (name && !/\/./.test(name)) {
results.push({
name: name.replace("/", ""),
isDir: name.endsWith("/"),
});
}
}
return results;
} finally {
zipReader.close();
}
}),
readJSON(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let uri = this.rootURI.resolve(`./${path}`);
NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => { NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => {
if (!Components.isSuccessCode(status)) { if (!Components.isSuccessCode(status)) {
reject(status); reject(new Error(status));
return; return;
} }
let text = NetUtil.readInputStreamToString(inputStream, inputStream.available());
try { try {
let text = NetUtil.readInputStreamToString(inputStream, inputStream.available());
resolve(JSON.parse(text)); resolve(JSON.parse(text));
} catch (e) { } catch (e) {
reject(e); reject(e);
@ -420,69 +543,108 @@ ExtensionData.prototype = {
}); });
}, },
// Reads the extension's |manifest.json| file, and stores its
// parsed contents in |this.manifest|.
readManifest() { readManifest() {
let manifestURI = Services.io.newURI("manifest.json", null, this.baseURI); return this.readJSON("manifest.json").then(manifest => {
return this.readJSON(manifestURI); this.manifest = manifest;
},
readLocaleFile(locale) {
let dir = locale.replace("-", "_");
let url = `_locales/${dir}/messages.json`;
let uri = Services.io.newURI(url, null, this.baseURI);
return this.readJSON(uri);
},
readLocaleMessages() {
let locales = [];
// We need to base this off of this.addonData.resourceURI rather
// than baseURI since baseURI is a moz-extension URI, which always
// QIs to nsIFileURL.
let uri = Services.io.newURI("_locales", null, this.addonData.resourceURI);
if (uri instanceof Ci.nsIFileURL) {
let file = uri.file;
let enumerator;
try { try {
enumerator = file.directoryEntries; this.id = this.manifest.applications.gecko.id;
} catch (e) { } catch (e) {}
return {};
} if (typeof this.id != "string") {
while (enumerator.hasMoreElements()) { this.manifestError("Missing required `applications.gecko.id` property");
let file = enumerator.getNext().QueryInterface(Ci.nsIFile);
locales.push({
name: file.leafName,
locales: [file.leafName.replace("_", "-")]
});
} }
return manifest;
});
},
// Reads the locale file for the given Gecko-compatible locale code, and
// stores its parsed contents in |this.localeMessages.get(locale)|.
readLocaleFile: Task.async(function* (locale) {
let locales = yield this.promiseLocales();
let dir = locales.get(locale);
let file = `_locales/${dir}/messages.json`;
let messages = {};
try {
messages = yield this.readJSON(file);
} catch (e) {
this.packagingError(`Loading locale file ${file}: ${e}`);
} }
if (uri instanceof Ci.nsIJARURI && uri.JARFile instanceof Ci.nsIFileURL) { this.localeMessages.set(locale, messages);
let file = uri.JARFile.file; return messages;
let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(Ci.nsIZipReader); }),
try {
zipReader.open(file); // Reads the list of locales available in the extension, and returns a
let enumerator = zipReader.findEntries("_locales/*"); // Promise which resolves to a Map upon completion.
while (enumerator.hasMore()) { // Each map key is a Gecko-compatible locale code, and each value is the
let name = enumerator.getNext(); // "_locales" subdirectory containing that locale:
let match = name.match(new RegExp("_locales\/([^/]*)")); //
if (match && match[1]) { // Map(gecko-locale-code -> locale-directory-name)
locales.push({ promiseLocales() {
name: match[1], if (!this._promiseLocales) {
locales: [match[1].replace("_", "-")] this._promiseLocales = Task.spawn(function* () {
}); let locales = new Map();
let entries = yield this.readDirectory("_locales");
for (let file of entries) {
if (file.isDir) {
let locale = this.normalizeLocaleCode(file.name);
locales.set(locale, file.name);
} }
} }
} finally {
zipReader.close(); return locales;
} }.bind(this));
} }
let locale = Locale.findClosestLocale(locales); return this._promiseLocales;
if (locale) { },
return this.readLocaleFile(locale.name).catch(() => {});
// Reads the locale messages for all locales, and returns a promise which
// resolves to a Map of locale messages upon completion. Each key in the map
// is a Gecko-compatible locale code, and each value is a locale data object
// as returned by |readLocaleFile|.
initAllLocales: Task.async(function* () {
let locales = yield this.promiseLocales();
yield Promise.all(Array.from(locales.keys(),
locale => this.readLocaleFile(locale)));
let defaultLocale = this.defaultLocale;
if (defaultLocale) {
if (!locales.has(defaultLocale)) {
this.manifestError('Value for "default_locale" property must correspond to ' +
'a directory in "_locales/". Not found: ' +
JSON.stringify(`_locales/${default_locale}/`));
}
} else if (this.localeMessages.size) {
this.manifestError('The "default_locale" property is required when a ' +
'"_locales/" directory is present.');
} }
return {};
} return this.localeMessages;
}),
// Reads the locale file for the given Gecko-compatible locale code, or the
// default locale if no locale code is given, and sets it as the currently
// selected locale on success.
//
// If no locales are unavailable, resolves to |null|.
initLocale: Task.async(function* (locale = this.defaultLocale) {
if (locale == null) {
return null;
}
let localeData = yield this.readLocaleFile(locale);
this.selectedLocale = locale;
return localeData;
}),
}; };
// We create one instance of this class per extension. |addonData| // We create one instance of this class per extension. |addonData|
@ -506,7 +668,6 @@ this.Extension = function(addonData)
this.id = addonData.id; this.id = addonData.id;
this.baseURI = Services.io.newURI("moz-extension://" + uuid, null, null); this.baseURI = Services.io.newURI("moz-extension://" + uuid, null, null);
this.baseURI.QueryInterface(Ci.nsIURL); this.baseURI.QueryInterface(Ci.nsIURL);
this.logger = Log.repository.getLogger(LOGGER_ID_BASE + this.id.replace(/\./g, "-"));
this.principal = this.createPrincipal(); this.principal = this.createPrincipal();
this.views = new Set(); this.views = new Set();
@ -668,11 +829,6 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
return common == this.baseURI.spec; return common == this.baseURI.spec;
}, },
// Report an error about the extension's manifest file.
manifestError(message) {
this.logger.error(`Loading extension '${this.id}': ${message}`);
},
// Representation of the extension to send to content // Representation of the extension to send to content
// processes. This should include anything the content process might // processes. This should include anything the content process might
// need. // need.
@ -745,6 +901,24 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
this.onShutdown.delete(obj); this.onShutdown.delete(obj);
}, },
// Reads the locale file for the given Gecko-compatible locale code, or if
// no locale is given, the available locale closest to the UI locale.
// Sets the currently selected locale on success.
initLocale: Task.async(function* (locale = undefined) {
if (locale === undefined) {
let locales = yield this.promiseLocales();
let localeList = Object.keys(locales).map(locale => {
return { name: locale, locales: [locale] };
});
let match = Locale.findClosestLocale(localeList);
locale = match ? match.name : this.defaultLocale;
}
return ExtensionData.prototype.initLocale.call(this, locale);
}),
startup() { startup() {
try { try {
ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this); ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this);
@ -752,21 +926,20 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
return Promise.reject(e); return Promise.reject(e);
} }
return Promise.all([this.readManifest(), this.readLocaleMessages()]).then(([manifest, messages]) => { return this.readManifest().then(() => {
return this.initLocale();
}).then(() => {
if (this.hasShutdown) { if (this.hasShutdown) {
return; return;
} }
GlobalManager.init(this); GlobalManager.init(this);
this.manifest = manifest;
this.localeMessages = messages;
Management.emit("startup", this); Management.emit("startup", this);
return this.runManifest(manifest); return this.runManifest(this.manifest);
}).catch(e => { }).catch(e => {
dump(`Extension error: ${e} ${e.fileName}:${e.lineNumber}\n`); dump(`Extension error: ${e} ${e.filename}:${e.lineNumber}\n`);
Cu.reportError(e); Cu.reportError(e);
throw e; throw e;
}); });