--- a/extensions/spellcheck/hunspell/glue/mozHunspell.cpp
+++ b/extensions/spellcheck/hunspell/glue/mozHunspell.cpp
@@ -402,28 +402,38 @@ mozHunspell::LoadDictionaryList(bool aNo
LoadDictionariesFromDir(dictDir);
}
// find dictionaries from restartless extensions
for (int32_t i = 0; i < mDynamicDirectories.Count(); i++) {
LoadDictionariesFromDir(mDynamicDirectories[i]);
}
+ for (auto iter = mDynamicDictionaries.Iter(); iter.Done(); iter.Next()) {
+ mDictionaries.Put(iter.Key(), iter.Data());
+ }
+
+ DictionariesChanged(aNotifyChildProcesses);
+}
+
+void
+mozHunspell::DictionariesChanged(bool aNotifyChildProcesses)
+{
// Now we have finished updating the list of dictionaries, update the current
// dictionary and any editors which may use it.
mozInlineSpellChecker::UpdateCanEnableInlineSpellChecking();
if (aNotifyChildProcesses) {
ContentParent::NotifyUpdatedDictionaries();
}
// Check if the current dictionary is still available.
// If not, try to replace it with another dictionary of the same language.
if (!mDictionary.IsEmpty()) {
- rv = SetDictionary(mDictionary.get());
+ nsresult rv = SetDictionary(mDictionary.get());
if (NS_SUCCEEDED(rv))
return;
}
// If the current dictionary has gone, and we don't have a good replacement,
// set no current dictionary.
if (!mDictionary.IsEmpty()) {
SetDictionary(EmptyString().get());
@@ -651,8 +661,31 @@ NS_IMETHODIMP mozHunspell::RemoveDirecto
if (obs) {
obs->NotifyObservers(nullptr,
SPELLCHECK_DICTIONARY_REMOVE_NOTIFICATION,
nullptr);
}
#endif
return NS_OK;
}
+
+NS_IMETHODIMP mozHunspell::AddDictionary(const nsAString& aLang, nsIFile *aFile)
+{
+ NS_ENSURE_TRUE(aFile, NS_ERROR_INVALID_ARG);
+
+ mDynamicDictionaries.Put(aLang, aFile);
+ mDictionaries.Put(aLang, aFile);
+ DictionariesChanged(true);
+ return NS_OK;
+}
+
+NS_IMETHODIMP mozHunspell::RemoveDictionary(const nsAString& aLang, nsIFile *aFile)
+{
+ NS_ENSURE_TRUE(aFile, NS_ERROR_INVALID_ARG);
+
+ nsCOMPtr<nsIFile> file = mDynamicDictionaries.Get(aLang);
+ bool equal;
+ if (file && NS_SUCCEEDED(file->Equals(aFile, &equal)) && equal) {
+ mDynamicDictionaries.Remove(aLang);
+ LoadDictionaryList(true);
+ }
+ return NS_OK;
+}
--- a/extensions/spellcheck/hunspell/glue/mozHunspell.h
+++ b/extensions/spellcheck/hunspell/glue/mozHunspell.h
@@ -100,25 +100,28 @@ public:
// helper method for converting a word to the charset of the dictionary
nsresult ConvertCharset(const char16_t* aStr, std::string* aDst);
NS_DECL_NSIMEMORYREPORTER
protected:
virtual ~mozHunspell();
+ void DictionariesChanged(bool aNotifyChildProcesses);
+
nsCOMPtr<mozIPersonalDictionary> mPersonalDictionary;
mozilla::UniquePtr<mozilla::Encoder> mEncoder;
mozilla::UniquePtr<mozilla::Decoder> mDecoder;
// Hashtable matches dictionary name to .aff file
nsInterfaceHashtable<nsStringHashKey, nsIFile> mDictionaries;
nsString mDictionary;
nsString mLanguage;
nsCString mAffixFileName;
// dynamic dirs used to search for dictionaries
nsCOMArray<nsIFile> mDynamicDirectories;
+ nsInterfaceHashtable<nsStringHashKey, nsIFile> mDynamicDictionaries;
Hunspell *mHunspell;
};
#endif
--- a/extensions/spellcheck/idl/mozISpellCheckingEngine.idl
+++ b/extensions/spellcheck/idl/mozISpellCheckingEngine.idl
@@ -80,16 +80,28 @@ interface mozISpellCheckingEngine : nsIS
* Add dictionaries from a directory to the spell checker
*/
void addDirectory(in nsIFile dir);
/**
* Remove dictionaries from a directory from the spell checker
*/
void removeDirectory(in nsIFile dir);
+
+ /**
+ * Add a dictionary with the given language code and file path.
+ */
+ void addDictionary(in AString lang, in nsIFile dir);
+
+ /**
+ * Remove a dictionary with the given language code and path. If the path does
+ * not match that of the current entry with the given languate code, it is not
+ * removed.
+ */
+ void removeDictionary(in AString lang, in nsIFile dir);
};
%{C++
#define DICTIONARY_SEARCH_DIRECTORY "DictD"
#define DICTIONARY_SEARCH_DIRECTORY_LIST "DictDL"
#define SPELLCHECK_DICTIONARY_REMOVE_NOTIFICATION \
"spellcheck-dictionary-remove"
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1,16 +1,16 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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/. */
"use strict";
-var EXPORTED_SYMBOLS = ["Extension", "ExtensionData", "Langpack"];
+var EXPORTED_SYMBOLS = ["Dictionary", "Extension", "ExtensionData", "Langpack"];
/* exported Extension, ExtensionData */
/* globals Extension ExtensionData */
/*
* This file is the main entry point for extensions. When an extension
* loads, its bootstrap.js file creates a Extension instance
* and calls .startup() on it. It calls .shutdown() when the extension
@@ -58,25 +58,33 @@ XPCOMUtils.defineLazyModuleGetters(this,
});
XPCOMUtils.defineLazyGetter(
this, "processScript",
() => Cc["@mozilla.org/webextensions/extension-process-script;1"]
.getService().wrappedJSObject);
XPCOMUtils.defineLazyGetter(
+ this, "OSPath", () => {
+ let obj = {};
+ ChromeUtils.import("resource://gre/modules/osfile/ospath_unix.jsm", obj);
+ return obj;
+ });
+
+XPCOMUtils.defineLazyGetter(
this, "resourceProtocol",
() => Services.io.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler));
ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
XPCOMUtils.defineLazyServiceGetters(this, {
aomStartup: ["@mozilla.org/addons/addon-manager-startup;1", "amIAddonManagerStartup"],
+ spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"],
uuidGen: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
});
XPCOMUtils.defineLazyPreferenceGetter(this, "processCount", "dom.ipc.processCount.extension");
var {
GlobalManager,
ParentAPIManager,
@@ -368,16 +376,19 @@ class ExtensionData {
}
let uri = this.rootURI.QueryInterface(Ci.nsIJARURI);
let file = uri.JARFile.QueryInterface(Ci.nsIFileURL).file;
// Normalize the directory path.
path = `${uri.JAREntry}/${path}`;
path = path.replace(/\/\/+/g, "/").replace(/^\/|\/$/g, "") + "/";
+ if (path === "/") {
+ path = "";
+ }
// Escape pattern metacharacters.
let pattern = path.replace(/[[\]()?*~|$\\]/g, "\\$&") + "*";
let results = [];
for (let name of aomStartup.enumerateZipFile(file, pattern)) {
if (!name.startsWith(path)) {
throw new Error("Unexpected ZipReader entry");
@@ -520,16 +531,19 @@ class ExtensionData {
let manifestType = "manifest.WebExtensionManifest";
if (this.manifest.theme) {
this.type = "theme";
manifestType = "manifest.ThemeManifest";
} else if (this.manifest.langpack_id) {
this.type = "langpack";
manifestType = "manifest.WebExtensionLangpackManifest";
+ } else if (this.manifest.dictionaries) {
+ this.type = "dictionary";
+ manifestType = "manifest.WebExtensionDictionaryManifest";
} else {
this.type = "extension";
}
if (this.localeData) {
context.preprocessors.localize = (value, context) => this.localize(value);
}
@@ -708,16 +722,40 @@ class ExtensionData {
}
}
// 4. Save the list of languages handled by this langpack.
const languages = Object.keys(manifest.languages);
this.startupData = {chromeEntries, langpackId, l10nRegistrySources, languages};
+ } else if (this.type == "dictionary") {
+ let dictionaries = {};
+ for (let [lang, path] of Object.entries(manifest.dictionaries)) {
+ path = path.replace(/^\/+/, "");
+
+ let dir = OSPath.dirname(path);
+ if (dir === ".") {
+ dir = "";
+ }
+ let leafName = OSPath.basename(path);
+ let affixPath = leafName.slice(0, -3) + "aff";
+
+ let entries = Array.from(await this.readDirectory(dir), entry => entry.name);
+ if (!entries.includes(leafName)) {
+ this.manifestError(`Invalid dictionary path specified for '${lang}': ${path}`);
+ }
+ if (!entries.includes(affixPath)) {
+ this.manifestError(`Invalid dictionary path specified for '${lang}': Missing affix file: ${path}`);
+ }
+
+ dictionaries[lang] = path;
+ }
+
+ this.startupData = {dictionaries};
}
if (schemaPromises.size) {
let schemas = new Map();
for (let [url, promise] of schemaPromises) {
schemas.set(url, await promise);
}
result.schemaURLs = schemas;
@@ -1120,16 +1158,32 @@ XPCOMUtils.defineLazyGetter(BootstrapSco
[BOOTSTRAP_REASONS.ADDON_DISABLE]: "ADDON_DISABLE",
[BOOTSTRAP_REASONS.ADDON_INSTALL]: "ADDON_INSTALL",
[BOOTSTRAP_REASONS.ADDON_UNINSTALL]: "ADDON_UNINSTALL",
[BOOTSTRAP_REASONS.ADDON_UPGRADE]: "ADDON_UPGRADE",
[BOOTSTRAP_REASONS.ADDON_DOWNGRADE]: "ADDON_DOWNGRADE",
});
});
+class DictionaryBootstrapScope extends BootstrapScope {
+ install(data, reason) {}
+ uninstall(data, reason) {}
+
+ startup(data, reason) {
+ // eslint-disable-next-line no-use-before-define
+ this.dictionary = new Dictionary(data);
+ return this.dictionary.startup(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+ }
+
+ shutdown(data, reason) {
+ this.dictionary.shutdown(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+ this.dictionary = null;
+ }
+}
+
class LangpackBootstrapScope {
install(data, reason) {}
uninstall(data, reason) {}
startup(data, reason) {
// eslint-disable-next-line no-use-before-define
this.langpack = new Langpack(data);
return this.langpack.startup();
@@ -1799,16 +1853,52 @@ class Extension extends ExtensionData {
if (this._optionalOrigins == null) {
let origins = this.manifest.optional_permissions.filter(perm => classifyPermission(perm).origin);
this._optionalOrigins = new MatchPatternSet(origins, {ignorePath: true});
}
return this._optionalOrigins;
}
}
+class Dictionary extends ExtensionData {
+ constructor(addonData, startupReason) {
+ super(addonData.resourceURI);
+ this.id = addonData.id;
+ this.startupData = addonData.startupData;
+ }
+
+ static getBootstrapScope(id, file) {
+ return new DictionaryBootstrapScope();
+ }
+
+ async startup(reason) {
+ this.dictionaries = {};
+ for (let [lang, path] of Object.entries(this.startupData.dictionaries)) {
+ let {file} = Services.io.newURI(path, null, this.rootURI).QueryInterface(Ci.nsIFileURL);
+ this.dictionaries[lang] = file;
+
+ spellCheck.addDictionary(lang, file);
+ }
+
+ Management.emit("ready", this);
+ }
+
+ async shutdown(reason) {
+ if (reason !== "APP_SHUTDOWN") {
+ for (let [lang, file] of Object.entries(this.dictionaries)) {
+ spellCheck.removeDictionary(lang, file);
+ }
+ // Make sure that any dictionary we provided is no longer used.
+ if (spellCheck.dictionary in this.dictionaries) {
+ spellCheck.dictionary = spellCheck.dictionary;
+ }
+ }
+ }
+}
+
class Langpack extends ExtensionData {
constructor(addonData, startupReason) {
super(addonData.resourceURI);
this.startupData = addonData.startupData;
this.manifestCacheKey = [addonData.id, addonData.version];
}
static getBootstrapScope(id, file) {
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -296,16 +296,41 @@
}
}
}
}
}
}
},
{
+ "id": "WebExtensionDictionaryManifest",
+ "type": "object",
+ "description": "Represents a WebExtension dictionary manifest.json file",
+
+ "$import": "ManifestBase",
+ "properties": {
+ "homepage_url": {
+ "type": "string",
+ "format": "url",
+ "optional": true,
+ "preprocess": "localize"
+ },
+
+ "dictionaries": {
+ "type": "object",
+ "patternProperties": {
+ "^[a-z]{2}[a-zA-Z-]*$": {
+ "$ref": "ExtensionURL",
+ "pattern": "\\.dic$"
+ }
+ }
+ }
+ }
+ },
+ {
"id": "ThemeIcons",
"type": "object",
"properties": {
"light": {
"$ref": "ExtensionURL",
"description": "A light icon to use for dark themes"
},
"dark": {
--- a/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
@@ -320,17 +320,17 @@ class AddonInternal {
}
if (this.signedState === AddonManager.SIGNEDSTATE_NOT_REQUIRED)
return true;
return this.signedState > AddonManager.SIGNEDSTATE_MISSING;
}
get unpack() {
- return this.type === "dictionary";
+ return this.type === "dictionary" || this.type === "webextension-dictionary";
}
get isCompatible() {
return this.isCompatibleWith();
}
get disabled() {
return (this.userDisabled || this.appDisabled || this.softDisabled);
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -191,16 +191,17 @@ const TYPES = {
const COMPATIBLE_BY_DEFAULT_TYPES = {
extension: true,
dictionary: true,
};
const RESTARTLESS_TYPES = new Set([
"dictionary",
"webextension",
+ "webextension-dictionary",
"webextension-theme",
]);
// This is a random number array that can be used as "salt" when generating
// an automatic ID based on the directory path of an add-on. It will prevent
// someone from creating an ID for a permanent add-on that could be replaced
// by a temporary add-on (because that would be confusing, I guess).
const TEMP_INSTALL_ID_GEN_SESSION =
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -23,16 +23,17 @@ var EXPORTED_SYMBOLS = ["XPIProvider", "
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
AppConstants: "resource://gre/modules/AppConstants.jsm",
+ Dictionary: "resource://gre/modules/Extension.jsm",
Extension: "resource://gre/modules/Extension.jsm",
Langpack: "resource://gre/modules/Extension.jsm",
LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
FileUtils: "resource://gre/modules/FileUtils.jsm",
PermissionsUtils: "resource://gre/modules/PermissionsUtils.jsm",
OS: "resource://gre/modules/osfile.jsm",
ConsoleAPI: "resource://gre/modules/Console.jsm",
JSONFile: "resource://gre/modules/JSONFile.jsm",
@@ -153,18 +154,19 @@ const BOOTSTRAP_REASONS = {
ADDON_UPGRADE: 7,
ADDON_DOWNGRADE: 8
};
// Some add-on types that we track internally are presented as other types
// externally
const TYPE_ALIASES = {
"webextension": "extension",
+ "webextension-dictionary": "dictionary",
+ "webextension-langpack": "locale",
"webextension-theme": "theme",
- "webextension-langpack": "locale",
};
const CHROME_TYPES = new Set([
"extension",
]);
const SIGNED_TYPES = new Set([
"extension",
@@ -2572,16 +2574,18 @@ var XPIProvider = {
logger.error("Attempted to load bootstrap scope from missing directory " + aFile.path);
return;
}
if (isWebExtension(aType)) {
activeAddon.bootstrapScope = Extension.getBootstrapScope(aId, aFile);
} else if (aType === "webextension-langpack") {
activeAddon.bootstrapScope = Langpack.getBootstrapScope(aId, aFile);
+ } else if (aType === "webextension-dictionary") {
+ activeAddon.bootstrapScope = Dictionary.getBootstrapScope(aId, aFile);
} else {
let uri = getURIForResourceInFile(aFile, "bootstrap.js").spec;
if (aType == "dictionary")
uri = "resource://gre/modules/addons/SpellCheckDictionaryBootstrap.js";
activeAddon.bootstrapScope =
new Cu.Sandbox(principal, { sandboxName: uri,
addonId: aId,
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -1150,22 +1150,23 @@ function installAllFiles(aFiles, aCallba
const EXTENSIONS_DB = "extensions.json";
var gExtensionsJSON = gProfD.clone();
gExtensionsJSON.append(EXTENSIONS_DB);
function promiseInstallWebExtension(aData) {
let addonFile = createTempWebExtensionFile(aData);
+ let promise = promiseWebExtensionStartup();
return promiseInstallAllFiles([addonFile]).then(installs => {
Services.obs.notifyObservers(addonFile, "flush-cache-entry");
// Since themes are disabled by default, it won't start up.
if (aData.manifest.theme)
return installs[0].addon;
- return promiseWebExtensionStartup();
+ return promise.then(() => installs[0].addon);
});
}
// By default use strict compatibility
Services.prefs.setBoolPref("extensions.strictCompatibility", true);
// By default, set min compatible versions to 0
Services.prefs.setCharPref(PREF_EM_MIN_COMPAT_APP_VERSION, "0");
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(this, "spellCheck",
+ "@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine");
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "61", "61");
+
+ await promiseStartupManager();
+});
+
+add_task(async function test_validation() {
+ await Assert.rejects(
+ promiseInstallWebExtension({
+ manifest: {
+ applications: {gecko: {id: "en-US-no-dic@dictionaries.mozilla.org"}},
+ "dictionaries": {
+ "en-US": "en-US.dic",
+ },
+ },
+ })
+ );
+
+ await Assert.rejects(
+ promiseInstallWebExtension({
+ manifest: {
+ applications: {gecko: {id: "en-US-no-aff@dictionaries.mozilla.org"}},
+ "dictionaries": {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": "",
+ },
+ })
+ );
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ applications: {gecko: {id: "en-US-1@dictionaries.mozilla.org"}},
+ "dictionaries": {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": "",
+ "en-US.aff": "",
+ },
+ });
+
+ let addon2 = await promiseInstallWebExtension({
+ manifest: {
+ applications: {gecko: {id: "en-US-2@dictionaries.mozilla.org"}},
+ "dictionaries": {
+ "en-US": "dictionaries/en-US.dic",
+ },
+ },
+
+ files: {
+ "dictionaries/en-US.dic": "",
+ "dictionaries/en-US.aff": "",
+ },
+ });
+
+ addon.uninstall();
+ addon2.uninstall();
+});
+
+add_task(async function test_registration() {
+ const WORD = "Flehgragh";
+
+ spellCheck.dictionary = "en-US";
+
+ ok(!spellCheck.check(WORD), "Word should not pass check before add-on loads");
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ applications: {gecko: {id: "en-US@dictionaries.mozilla.org"}},
+ "dictionaries": {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": `1\n${WORD}\n`,
+ "en-US.aff": "",
+ },
+ });
+
+ ok(spellCheck.check(WORD), "Word should pass check while add-on load is loaded");
+
+ addon.uninstall();
+
+ await new Promise(executeSoon);
+
+ ok(!spellCheck.check(WORD), "Word should not pass check after add-on unloads");
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini
@@ -7,9 +7,10 @@ dupe-manifest =
tags = addons
[test_webextension_paths.js]
tags = webextensions
[test_webextension_theme.js]
tags = webextensions
[test_dictionary.js]
+[test_dictionary_webextension.js]
[test_filepointer.js]