mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-04 16:15:25 +00:00
562 lines
21 KiB
JavaScript
562 lines
21 KiB
JavaScript
# -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
|
|
# 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/.
|
|
|
|
var gPluginHandler = {
|
|
PREF_SESSION_PERSIST_MINUTES: "plugin.sessionPermissionNow.intervalInMinutes",
|
|
PREF_PERSISTENT_DAYS: "plugin.persistentPermissionAlways.intervalInDays",
|
|
MESSAGES: [
|
|
"PluginContent:ShowClickToPlayNotification",
|
|
"PluginContent:RemoveNotification",
|
|
"PluginContent:UpdateHiddenPluginUI",
|
|
"PluginContent:HideNotificationBar",
|
|
"PluginContent:ShowInstallNotification",
|
|
"PluginContent:InstallSinglePlugin",
|
|
"PluginContent:ShowPluginCrashedNotification",
|
|
"PluginContent:SubmitReport",
|
|
"PluginContent:LinkClickCallback",
|
|
],
|
|
|
|
init: function () {
|
|
const mm = window.messageManager;
|
|
for (let msg of this.MESSAGES) {
|
|
mm.addMessageListener(msg, this);
|
|
}
|
|
window.addEventListener("unload", this);
|
|
},
|
|
|
|
uninit: function () {
|
|
const mm = window.messageManager;
|
|
for (let msg of this.MESSAGES) {
|
|
mm.removeMessageListener(msg, this);
|
|
}
|
|
window.removeEventListener("unload", this);
|
|
},
|
|
|
|
handleEvent: function (event) {
|
|
if (event.type == "unload") {
|
|
this.uninit();
|
|
}
|
|
},
|
|
|
|
receiveMessage: function (msg) {
|
|
switch (msg.name) {
|
|
case "PluginContent:ShowClickToPlayNotification":
|
|
this.showClickToPlayNotification(msg.target, msg.data.plugins, msg.data.showNow,
|
|
msg.principal, msg.data.location);
|
|
break;
|
|
case "PluginContent:RemoveNotification":
|
|
this.removeNotification(msg.target, msg.data.name);
|
|
break;
|
|
case "PluginContent:UpdateHiddenPluginUI":
|
|
this.updateHiddenPluginUI(msg.target, msg.data.haveInsecure, msg.data.actions,
|
|
msg.principal, msg.data.location);
|
|
break;
|
|
case "PluginContent:HideNotificationBar":
|
|
this.hideNotificationBar(msg.target, msg.data.name);
|
|
break;
|
|
case "PluginContent:ShowInstallNotification":
|
|
return this.showInstallNotification(msg.target, msg.data.pluginInfo);
|
|
case "PluginContent:InstallSinglePlugin":
|
|
this.installSinglePlugin(msg.data.pluginInfo);
|
|
break;
|
|
case "PluginContent:ShowPluginCrashedNotification":
|
|
this.showPluginCrashedNotification(msg.target, msg.data.messageString,
|
|
msg.data.pluginID);
|
|
break;
|
|
case "PluginContent:SubmitReport":
|
|
if (AppConstants.MOZ_CRASHREPORTER) {
|
|
this.submitReport(msg.data.runID, msg.data.keyVals, msg.data.submitURLOptIn);
|
|
}
|
|
break;
|
|
case "PluginContent:LinkClickCallback":
|
|
switch (msg.data.name) {
|
|
case "managePlugins":
|
|
case "openHelpPage":
|
|
case "openPluginUpdatePage":
|
|
this[msg.data.name].apply(this);
|
|
break;
|
|
}
|
|
break;
|
|
default:
|
|
Cu.reportError("gPluginHandler did not expect to handle message " + msg.name);
|
|
break;
|
|
}
|
|
},
|
|
|
|
#ifdef MOZ_CRASHREPORTER
|
|
get CrashSubmit() {
|
|
delete this.CrashSubmit;
|
|
Cu.import("resource://gre/modules/CrashSubmit.jsm", this);
|
|
return this.CrashSubmit;
|
|
},
|
|
#endif
|
|
|
|
// Callback for user clicking on a disabled plugin
|
|
managePlugins: function () {
|
|
BrowserOpenAddonsMgr("addons://list/plugin");
|
|
},
|
|
|
|
// Callback for user clicking on the link in a click-to-play plugin
|
|
// (where the plugin has an update)
|
|
openPluginUpdatePage: function () {
|
|
openUILinkIn(Services.urlFormatter.formatURLPref("plugins.update.url"), "tab");
|
|
},
|
|
|
|
submitReport: function submitReport(runID, keyVals, submitURLOptIn) {
|
|
if (!AppConstants.MOZ_CRASHREPORTER) {
|
|
return;
|
|
}
|
|
Services.prefs.setBoolPref("dom.ipc.plugins.reportCrashURL", submitURLOptIn);
|
|
PluginCrashReporter.submitCrashReport(runID, keyVals);
|
|
},
|
|
|
|
// Callback for user clicking a "reload page" link
|
|
reloadPage: function (browser) {
|
|
browser.reload();
|
|
},
|
|
|
|
// Callback for user clicking the help icon
|
|
openHelpPage: function () {
|
|
openHelpLink("plugin-crashed", false);
|
|
},
|
|
|
|
_clickToPlayNotificationEventCallback: function PH_ctpEventCallback(event) {
|
|
if (event == "showing") {
|
|
Services.telemetry.getHistogramById("PLUGINS_NOTIFICATION_SHOWN")
|
|
.add(!this.options.primaryPlugin);
|
|
// Histograms always start at 0, even though our data starts at 1
|
|
let histogramCount = this.options.pluginData.size - 1;
|
|
if (histogramCount > 4) {
|
|
histogramCount = 4;
|
|
}
|
|
Services.telemetry.getHistogramById("PLUGINS_NOTIFICATION_PLUGIN_COUNT")
|
|
.add(histogramCount);
|
|
}
|
|
else if (event == "dismissed") {
|
|
// Once the popup is dismissed, clicking the icon should show the full
|
|
// list again
|
|
this.options.primaryPlugin = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Called from the plugin doorhanger to set the new permissions for a plugin
|
|
* and activate plugins if necessary.
|
|
* aNewState should be either "allownow" "allowalways" or "block"
|
|
*/
|
|
_updatePluginPermission: function (aNotification, aPluginInfo, aNewState) {
|
|
let permission;
|
|
let expireType;
|
|
let expireTime;
|
|
let histogram =
|
|
Services.telemetry.getHistogramById("PLUGINS_NOTIFICATION_USER_ACTION");
|
|
|
|
// Update the permission manager.
|
|
// Also update the current state of pluginInfo.fallbackType so that
|
|
// subsequent opening of the notification shows the current state.
|
|
switch (aNewState) {
|
|
case "allownow":
|
|
permission = Ci.nsIPermissionManager.ALLOW_ACTION;
|
|
expireType = Ci.nsIPermissionManager.EXPIRE_SESSION;
|
|
expireTime = Date.now() + Services.prefs.getIntPref(this.PREF_SESSION_PERSIST_MINUTES) * 60 * 1000;
|
|
histogram.add(0);
|
|
aPluginInfo.fallbackType = Ci.nsIObjectLoadingContent.PLUGIN_ACTIVE;
|
|
break;
|
|
|
|
case "allowalways":
|
|
permission = Ci.nsIPermissionManager.ALLOW_ACTION;
|
|
expireType = Ci.nsIPermissionManager.EXPIRE_TIME;
|
|
expireTime = Date.now() +
|
|
Services.prefs.getIntPref(this.PREF_PERSISTENT_DAYS) * 24 * 60 * 60 * 1000;
|
|
histogram.add(1);
|
|
aPluginInfo.fallbackType = Ci.nsIObjectLoadingContent.PLUGIN_ACTIVE;
|
|
break;
|
|
|
|
case "block":
|
|
permission = Ci.nsIPermissionManager.PROMPT_ACTION;
|
|
expireType = Ci.nsIPermissionManager.EXPIRE_NEVER;
|
|
expireTime = 0;
|
|
histogram.add(2);
|
|
switch (aPluginInfo.blocklistState) {
|
|
case Ci.nsIBlocklistService.STATE_VULNERABLE_UPDATE_AVAILABLE:
|
|
aPluginInfo.fallbackType = Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE;
|
|
break;
|
|
case Ci.nsIBlocklistService.STATE_VULNERABLE_NO_UPDATE:
|
|
aPluginInfo.fallbackType = Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE;
|
|
break;
|
|
default:
|
|
aPluginInfo.fallbackType = Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY;
|
|
}
|
|
break;
|
|
|
|
// In case a plugin has already been allowed in another tab, the "continue allowing" button
|
|
// shouldn't change any permissions but should run the plugin-enablement code below.
|
|
case "continue":
|
|
aPluginInfo.fallbackType = Ci.nsIObjectLoadingContent.PLUGIN_ACTIVE;
|
|
break;
|
|
default:
|
|
Cu.reportError(Error("Unexpected plugin state: " + aNewState));
|
|
return;
|
|
}
|
|
|
|
let browser = aNotification.browser;
|
|
let contentWindow = browser.contentWindow;
|
|
if (aNewState != "continue") {
|
|
let principal = aNotification.options.principal;
|
|
Services.perms.addFromPrincipal(principal, aPluginInfo.permissionString,
|
|
permission, expireType, expireTime);
|
|
aPluginInfo.pluginPermissionType = expireType;
|
|
}
|
|
|
|
browser.messageManager.sendAsyncMessage("BrowserPlugins:ActivatePlugins", {
|
|
pluginInfo: aPluginInfo,
|
|
newState: aNewState,
|
|
});
|
|
},
|
|
|
|
showClickToPlayNotification: function (browser, plugins, showNow,
|
|
principal, location) {
|
|
// It is possible that we've received a message from the frame script to show
|
|
// a click to play notification for a principal that no longer matches the one
|
|
// that the browser's content now has assigned (ie, the browser has browsed away
|
|
// after the message was sent, but before the message was received). In that case,
|
|
// we should just ignore the message.
|
|
if (!principal.equals(browser.contentPrincipal)) {
|
|
return;
|
|
}
|
|
|
|
// Data URIs, when linked to from some page, inherit the principal of that
|
|
// page. That means that we also need to compare the actual locations to
|
|
// ensure we aren't getting a message from a Data URI that we're no longer
|
|
// looking at.
|
|
let receivedURI = BrowserUtils.makeURI(location);
|
|
if (!browser.documentURI.equalsExceptRef(receivedURI)) {
|
|
return;
|
|
}
|
|
|
|
let notification = PopupNotifications.getNotification("click-to-play-plugins", browser);
|
|
|
|
// If this is a new notification, create a pluginData map, otherwise append
|
|
let pluginData;
|
|
if (notification) {
|
|
pluginData = notification.options.pluginData;
|
|
} else {
|
|
pluginData = new Map();
|
|
}
|
|
|
|
for (var pluginInfo of plugins) {
|
|
if (pluginData.has(pluginInfo.permissionString)) {
|
|
continue;
|
|
}
|
|
|
|
// If a block contains an infoURL, we should always prefer that to the default
|
|
// URL that we construct in-product, even for other blocklist types.
|
|
let url = Services.blocklist.getPluginInfoURL(pluginInfo.pluginTag);
|
|
|
|
if (pluginInfo.blocklistState == Ci.nsIBlocklistService.STATE_VULNERABLE_UPDATE_AVAILABLE) {
|
|
if (!url) {
|
|
url = Services.urlFormatter.formatURLPref("plugins.update.url");
|
|
}
|
|
}
|
|
else if (pluginInfo.blocklistState != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
|
|
if (!url) {
|
|
url = Services.blocklist.getPluginBlocklistURL(pluginInfo.pluginTag);
|
|
}
|
|
}
|
|
else {
|
|
url = Services.urlFormatter.formatURLPref("app.support.baseURL") + "clicktoplay";
|
|
}
|
|
pluginInfo.detailsLink = url;
|
|
|
|
pluginData.set(pluginInfo.permissionString, pluginInfo);
|
|
}
|
|
|
|
let primaryPluginPermission = null;
|
|
if (showNow) {
|
|
primaryPluginPermission = plugins[0].permissionString;
|
|
}
|
|
|
|
if (notification) {
|
|
// Don't modify the notification UI while it's on the screen, that would be
|
|
// jumpy and might allow clickjacking.
|
|
if (showNow) {
|
|
notification.options.primaryPlugin = primaryPluginPermission;
|
|
notification.reshow();
|
|
browser.messageManager.sendAsyncMessage("BrowserPlugins:NotificationShown");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let options = {
|
|
dismissed: !showNow,
|
|
eventCallback: this._clickToPlayNotificationEventCallback,
|
|
primaryPlugin: primaryPluginPermission,
|
|
pluginData: pluginData,
|
|
principal: principal,
|
|
};
|
|
PopupNotifications.show(browser, "click-to-play-plugins",
|
|
"", "plugins-notification-icon",
|
|
null, null, options);
|
|
browser.messageManager.sendAsyncMessage("BrowserPlugins:NotificationShown");
|
|
},
|
|
|
|
removeNotification: function (browser, name) {
|
|
let notification = PopupNotifications.getNotification(name, browser);
|
|
if (notification)
|
|
PopupNotifications.remove(notification);
|
|
},
|
|
|
|
hideNotificationBar: function (browser, name) {
|
|
let notificationBox = gBrowser.getNotificationBox(browser);
|
|
let notification = notificationBox.getNotificationWithValue(name);
|
|
if (notification)
|
|
notificationBox.removeNotification(notification, true);
|
|
},
|
|
|
|
updateHiddenPluginUI: function (browser, haveInsecure, actions,
|
|
principal, location) {
|
|
let origin = principal.originNoSuffix;
|
|
|
|
// It is possible that we've received a message from the frame script to show
|
|
// the hidden plugin notification for a principal that no longer matches the one
|
|
// that the browser's content now has assigned (ie, the browser has browsed away
|
|
// after the message was sent, but before the message was received). In that case,
|
|
// we should just ignore the message.
|
|
if (!principal.equals(browser.contentPrincipal)) {
|
|
return;
|
|
}
|
|
|
|
// Data URIs, when linked to from some page, inherit the principal of that
|
|
// page. That means that we also need to compare the actual locations to
|
|
// ensure we aren't getting a message from a Data URI that we're no longer
|
|
// looking at.
|
|
let receivedURI = BrowserUtils.makeURI(location);
|
|
if (!browser.documentURI.equalsExceptRef(receivedURI)) {
|
|
return;
|
|
}
|
|
|
|
// Set up the icon
|
|
document.getElementById("plugins-notification-icon").classList.
|
|
toggle("plugin-blocked", haveInsecure);
|
|
|
|
// Now configure the notification bar
|
|
let notificationBox = gBrowser.getNotificationBox(browser);
|
|
|
|
function hideNotification() {
|
|
let n = notificationBox.getNotificationWithValue("plugin-hidden");
|
|
if (n) {
|
|
notificationBox.removeNotification(n, true);
|
|
}
|
|
}
|
|
|
|
// There are three different cases when showing an infobar:
|
|
// 1. A single type of plugin is hidden on the page. Show the UI for that
|
|
// plugin.
|
|
// 2a. Multiple types of plugins are hidden on the page. Show the multi-UI
|
|
// with the vulnerable styling.
|
|
// 2b. Multiple types of plugins are hidden on the page, but none are
|
|
// vulnerable. Show the nonvulnerable multi-UI.
|
|
function showNotification() {
|
|
let n = notificationBox.getNotificationWithValue("plugin-hidden");
|
|
if (n) {
|
|
// If something is already shown, just keep it
|
|
return;
|
|
}
|
|
|
|
Services.telemetry.getHistogramById("PLUGINS_INFOBAR_SHOWN").
|
|
add(true);
|
|
|
|
let message;
|
|
// Icons set directly cannot be manipulated using moz-image-region, so
|
|
// we use CSS classes instead.
|
|
let brand = document.getElementById("bundle_brand").getString("brandShortName");
|
|
|
|
if (actions.length == 1) {
|
|
let pluginInfo = actions[0];
|
|
let pluginName = pluginInfo.pluginName;
|
|
|
|
switch (pluginInfo.fallbackType) {
|
|
case Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY:
|
|
message = gNavigatorBundle.getFormattedString(
|
|
"pluginActivateNew.message",
|
|
[pluginName, origin]);
|
|
break;
|
|
case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE:
|
|
message = gNavigatorBundle.getFormattedString(
|
|
"pluginActivateOutdated.message",
|
|
[pluginName, origin, brand]);
|
|
break;
|
|
case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE:
|
|
message = gNavigatorBundle.getFormattedString(
|
|
"pluginActivateVulnerable.message",
|
|
[pluginName, origin, brand]);
|
|
}
|
|
} else {
|
|
// Multi-plugin
|
|
message = gNavigatorBundle.getFormattedString(
|
|
"pluginActivateMultiple.message", [origin]);
|
|
}
|
|
|
|
let buttons = [
|
|
{
|
|
label: gNavigatorBundle.getString("pluginContinueBlocking.label"),
|
|
accessKey: gNavigatorBundle.getString("pluginContinueBlocking.accesskey"),
|
|
callback: function() {
|
|
Services.telemetry.getHistogramById("PLUGINS_INFOBAR_BLOCK").
|
|
add(true);
|
|
|
|
Services.perms.addFromPrincipal(principal,
|
|
"plugin-hidden-notification",
|
|
Services.perms.DENY_ACTION);
|
|
}
|
|
},
|
|
{
|
|
label: gNavigatorBundle.getString("pluginActivateTrigger.label"),
|
|
accessKey: gNavigatorBundle.getString("pluginActivateTrigger.accesskey"),
|
|
callback: function() {
|
|
Services.telemetry.getHistogramById("PLUGINS_INFOBAR_ALLOW").
|
|
add(true);
|
|
|
|
let curNotification =
|
|
PopupNotifications.getNotification("click-to-play-plugins",
|
|
browser);
|
|
if (curNotification) {
|
|
curNotification.reshow();
|
|
}
|
|
}
|
|
}
|
|
];
|
|
n = notificationBox.
|
|
appendNotification(message, "plugin-hidden", null,
|
|
notificationBox.PRIORITY_INFO_HIGH, buttons);
|
|
if (haveInsecure) {
|
|
n.classList.add('pluginVulnerable');
|
|
}
|
|
}
|
|
|
|
if (actions.length == 0) {
|
|
hideNotification();
|
|
} else {
|
|
let notificationPermission = Services.perms.testPermissionFromPrincipal(
|
|
principal, "plugin-hidden-notification");
|
|
if (notificationPermission == Ci.nsIPermissionManager.DENY_ACTION) {
|
|
hideNotification();
|
|
} else {
|
|
showNotification();
|
|
}
|
|
}
|
|
},
|
|
|
|
contextMenuCommand: function (browser, plugin, command) {
|
|
browser.messageManager.sendAsyncMessage("BrowserPlugins:ContextMenuCommand",
|
|
{ command: command }, { plugin: plugin });
|
|
},
|
|
|
|
// Crashed-plugin observer. Notified once per plugin crash, before events
|
|
// are dispatched to individual plugin instances.
|
|
NPAPIPluginCrashed : function(subject, topic, data) {
|
|
let propertyBag = subject;
|
|
if (!(propertyBag instanceof Ci.nsIPropertyBag2) ||
|
|
!(propertyBag instanceof Ci.nsIWritablePropertyBag2) ||
|
|
!propertyBag.hasKey("runID") ||
|
|
!propertyBag.hasKey("pluginName")) {
|
|
Cu.reportError("A NPAPI plugin crashed, but the properties of this plugin " +
|
|
"cannot be read.");
|
|
return;
|
|
}
|
|
|
|
let runID = propertyBag.getPropertyAsUint32("runID");
|
|
let uglyPluginName = propertyBag.getPropertyAsAString("pluginName");
|
|
let pluginName = BrowserUtils.makeNicePluginName(uglyPluginName);
|
|
let pluginDumpID = propertyBag.getPropertyAsAString("pluginDumpID");
|
|
|
|
// If we don't have a minidumpID, we can't (or didn't) submit anything.
|
|
// This can happen if the plugin is killed from the task manager.
|
|
let state;
|
|
if (!AppConstants.MOZ_CRASHREPORTER || !gCrashReporter.enabled) {
|
|
// This state tells the user that crash reporting is disabled, so we
|
|
// cannot send a report.
|
|
state = "noSubmit";
|
|
} else if (!pluginDumpID) {
|
|
// This state tells the user that there is no crash report available.
|
|
state = "noReport";
|
|
} else {
|
|
// This state asks the user to submit a crash report.
|
|
state = "please";
|
|
}
|
|
|
|
let mm = window.getGroupMessageManager("browsers");
|
|
mm.broadcastAsyncMessage("BrowserPlugins:NPAPIPluginProcessCrashed",
|
|
{ pluginName, runID, state });
|
|
},
|
|
|
|
/**
|
|
* Shows a plugin-crashed notification bar for a browser that has had an
|
|
* invisiable NPAPI plugin crash, or a GMP plugin crash.
|
|
*
|
|
* @param browser
|
|
* The browser to show the notification for.
|
|
* @param messageString
|
|
* The string to put in the notification bar
|
|
* @param pluginID
|
|
* The unique-per-process identifier for the NPAPI plugin or GMP.
|
|
* For a GMP, this is the pluginID. For NPAPI plugins (where "pluginID"
|
|
* means something different), this is the runID.
|
|
*/
|
|
showPluginCrashedNotification: function (browser, messageString, pluginID) {
|
|
// If there's already an existing notification bar, don't do anything.
|
|
let notificationBox = gBrowser.getNotificationBox(browser);
|
|
let notification = notificationBox.getNotificationWithValue("plugin-crashed");
|
|
if (notification) {
|
|
return;
|
|
}
|
|
|
|
// Configure the notification bar
|
|
let priority = notificationBox.PRIORITY_WARNING_MEDIUM;
|
|
let iconURL = "chrome://mozapps/skin/plugins/notifyPluginCrashed.png";
|
|
let reloadLabel = gNavigatorBundle.getString("crashedpluginsMessage.reloadButton.label");
|
|
let reloadKey = gNavigatorBundle.getString("crashedpluginsMessage.reloadButton.accesskey");
|
|
|
|
let buttons = [{
|
|
label: reloadLabel,
|
|
accessKey: reloadKey,
|
|
popup: null,
|
|
callback: function() { browser.reload(); },
|
|
}];
|
|
|
|
if (AppConstants.MOZ_CRASHREPORTER &&
|
|
PluginCrashReporter.hasCrashReport(pluginID)) {
|
|
let submitLabel = gNavigatorBundle.getString("crashedpluginsMessage.submitButton.label");
|
|
let submitKey = gNavigatorBundle.getString("crashedpluginsMessage.submitButton.accesskey");
|
|
let submitButton = {
|
|
label: submitLabel,
|
|
accessKey: submitKey,
|
|
popup: null,
|
|
callback: () => {
|
|
PluginCrashReporter.submitCrashReport(pluginID);
|
|
},
|
|
};
|
|
|
|
buttons.push(submitButton);
|
|
}
|
|
|
|
notification = notificationBox.appendNotification(messageString, "plugin-crashed",
|
|
iconURL, priority, buttons);
|
|
|
|
// Add the "learn more" link.
|
|
let XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
|
|
let link = notification.ownerDocument.createElementNS(XULNS, "label");
|
|
link.className = "text-link";
|
|
link.setAttribute("value", gNavigatorBundle.getString("crashedpluginsMessage.learnMore"));
|
|
let crashurl = formatURL("app.support.baseURL", true);
|
|
crashurl += "plugin-crashed-notificationbar";
|
|
link.href = crashurl;
|
|
let description = notification.ownerDocument.getAnonymousElementByAttribute(notification, "anonid", "messageText");
|
|
description.appendChild(link);
|
|
},
|
|
};
|
|
|
|
gPluginHandler.init();
|