extensionSystem: Split off the extension downloader into a separate file
This keeps all UI out of the extension system, leaving it strictly for loading and unloading extensions. https://bugzilla.gnome.org/show_bug.cgi?id=677586
This commit is contained in:
parent
496e9f7b16
commit
3ff51da529
@ -52,6 +52,7 @@ nobase_dist_js_DATA = \
|
|||||||
ui/endSessionDialog.js \
|
ui/endSessionDialog.js \
|
||||||
ui/environment.js \
|
ui/environment.js \
|
||||||
ui/extensionSystem.js \
|
ui/extensionSystem.js \
|
||||||
|
ui/extensionDownloader.js \
|
||||||
ui/flashspot.js \
|
ui/flashspot.js \
|
||||||
ui/iconGrid.js \
|
ui/iconGrid.js \
|
||||||
ui/keyboard.js \
|
ui/keyboard.js \
|
||||||
|
171
js/ui/extensionDownloader.js
Normal file
171
js/ui/extensionDownloader.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
|
||||||
|
|
||||||
|
const Lang = imports.lang;
|
||||||
|
|
||||||
|
const Clutter = imports.gi.Clutter;
|
||||||
|
const GLib = imports.gi.GLib;
|
||||||
|
const Gio = imports.gi.Gio;
|
||||||
|
const Soup = imports.gi.Soup;
|
||||||
|
const St = imports.gi.St;
|
||||||
|
|
||||||
|
const Config = imports.misc.config;
|
||||||
|
const ExtensionUtils = imports.misc.extensionUtils;
|
||||||
|
const ExtensionSystem = imports.ui.extensionSystem;
|
||||||
|
const FileUtils = imports.misc.fileUtils;
|
||||||
|
const ModalDialog = imports.ui.modalDialog;
|
||||||
|
|
||||||
|
const _signals = ExtensionSystem._signals;
|
||||||
|
|
||||||
|
const REPOSITORY_URL_BASE = 'https://extensions.gnome.org';
|
||||||
|
const REPOSITORY_URL_DOWNLOAD = REPOSITORY_URL_BASE + '/download-extension/%s.shell-extension.zip';
|
||||||
|
const REPOSITORY_URL_INFO = REPOSITORY_URL_BASE + '/extension-info/';
|
||||||
|
|
||||||
|
let _httpSession;
|
||||||
|
|
||||||
|
function installExtensionFromUUID(uuid) {
|
||||||
|
let params = { uuid: uuid,
|
||||||
|
shell_version: Config.PACKAGE_VERSION };
|
||||||
|
|
||||||
|
let message = Soup.form_request_new_from_hash('GET', REPOSITORY_URL_INFO, params);
|
||||||
|
|
||||||
|
_httpSession.queue_message(message,
|
||||||
|
function(session, message) {
|
||||||
|
let info = JSON.parse(message.response_body.data);
|
||||||
|
let dialog = new InstallExtensionDialog(uuid, info);
|
||||||
|
dialog.open(global.get_current_time());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallExtensionFromUUID(uuid) {
|
||||||
|
let extension = ExtensionUtils.extensions[uuid];
|
||||||
|
if (!extension)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Try to disable it -- if it's ERROR'd, we can't guarantee that,
|
||||||
|
// but it will be removed on next reboot, and hopefully nothing
|
||||||
|
// broke too much.
|
||||||
|
ExtensionSystem.disableExtension(uuid);
|
||||||
|
|
||||||
|
// Don't try to uninstall system extensions
|
||||||
|
if (extension.type != ExtensionUtils.ExtensionType.PER_USER)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
extension.state = ExtensionSystem.ExtensionState.UNINSTALLED;
|
||||||
|
_signals.emit('extension-state-changed', extension);
|
||||||
|
|
||||||
|
delete ExtensionUtils.extensions[uuid];
|
||||||
|
|
||||||
|
FileUtils.recursivelyDeleteDir(Gio.file_new_for_path(extension.path));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotExtensionZipFile(session, message, uuid) {
|
||||||
|
if (message.status_code != Soup.KnownStatusCode.OK) {
|
||||||
|
logExtensionError(uuid, 'downloading extension: ' + message.status_code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let [file, stream] = Gio.File.new_tmp('XXXXXX.shell-extension.zip');
|
||||||
|
let dir = ExtensionUtils.userExtensionsDir.get_child(uuid);
|
||||||
|
let contents = message.response_body.flatten().as_bytes();
|
||||||
|
stream.output_stream.write_bytes(contents, null);
|
||||||
|
stream.close(null);
|
||||||
|
let [success, pid] = GLib.spawn_async(null,
|
||||||
|
['unzip', '-uod', dir.get_path(), '--', file.get_path()],
|
||||||
|
null,
|
||||||
|
GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD,
|
||||||
|
null);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
logExtensionError(uuid, 'extract: could not extract');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, function(pid, status) {
|
||||||
|
GLib.spawn_close_pid(pid);
|
||||||
|
|
||||||
|
// Add extension to 'enabled-extensions' for the user, always...
|
||||||
|
let enabledExtensions = global.settings.get_strv(ExtensionSystem.ENABLED_EXTENSIONS_KEY);
|
||||||
|
if (enabledExtensions.indexOf(uuid) == -1) {
|
||||||
|
enabledExtensions.push(uuid);
|
||||||
|
global.settings.set_strv(ExtensionSystem.ENABLED_EXTENSIONS_KEY, enabledExtensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionSystem.loadExtension(dir, ExtensionUtils.ExtensionType.PER_USER, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstallExtensionDialog = new Lang.Class({
|
||||||
|
Name: 'InstallExtensionDialog',
|
||||||
|
Extends: ModalDialog.ModalDialog,
|
||||||
|
|
||||||
|
_init: function(uuid, info) {
|
||||||
|
this.parent({ styleClass: 'extension-dialog' });
|
||||||
|
|
||||||
|
this._uuid = uuid;
|
||||||
|
this._info = info;
|
||||||
|
|
||||||
|
this.setButtons([{ label: _("Cancel"),
|
||||||
|
action: Lang.bind(this, this._onCancelButtonPressed),
|
||||||
|
key: Clutter.Escape
|
||||||
|
},
|
||||||
|
{ label: _("Install"),
|
||||||
|
action: Lang.bind(this, this._onInstallButtonPressed)
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let message = _("Download and install '%s' from extensions.gnome.org?").format(info.name);
|
||||||
|
|
||||||
|
let box = new St.BoxLayout();
|
||||||
|
this.contentLayout.add(box);
|
||||||
|
|
||||||
|
let gicon = new Gio.FileIcon({ file: Gio.File.new_for_uri(REPOSITORY_URL_BASE + info.icon) })
|
||||||
|
let icon = new St.Icon({ gicon: gicon });
|
||||||
|
box.add(icon);
|
||||||
|
|
||||||
|
let label = new St.Label({ text: message });
|
||||||
|
box.add(label);
|
||||||
|
},
|
||||||
|
|
||||||
|
_onCancelButtonPressed: function(button, event) {
|
||||||
|
this.close(global.get_current_time());
|
||||||
|
|
||||||
|
// Even though the extension is already "uninstalled", send through
|
||||||
|
// a state-changed signal for any users who want to know if the install
|
||||||
|
// went through correctly -- using proper async DBus would block more
|
||||||
|
// traditional clients like the plugin
|
||||||
|
let meta = { uuid: this._uuid,
|
||||||
|
state: ExtensionSystem.ExtensionState.UNINSTALLED,
|
||||||
|
error: '' };
|
||||||
|
|
||||||
|
_signals.emit('extension-state-changed', meta);
|
||||||
|
},
|
||||||
|
|
||||||
|
_onInstallButtonPressed: function(button, event) {
|
||||||
|
let state = { uuid: this._uuid,
|
||||||
|
state: ExtensionSystem.ExtensionState.DOWNLOADING,
|
||||||
|
error: '' };
|
||||||
|
|
||||||
|
_signals.emit('extension-state-changed', state);
|
||||||
|
|
||||||
|
let params = { shell_version: Config.PACKAGE_VERSION };
|
||||||
|
|
||||||
|
let url = REPOSITORY_URL_DOWNLOAD.format(this._uuid);
|
||||||
|
let message = Soup.form_request_new_from_hash('GET', url, params);
|
||||||
|
|
||||||
|
_httpSession.queue_message(message,
|
||||||
|
Lang.bind(this, function(session, message) {
|
||||||
|
gotExtensionZipFile(session, message, this._uuid);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.close(global.get_current_time());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
_httpSession = new Soup.SessionAsync({ ssl_use_system_ca_file: true });
|
||||||
|
|
||||||
|
// See: https://bugzilla.gnome.org/show_bug.cgi?id=655189 for context.
|
||||||
|
// _httpSession.add_feature(new Soup.ProxyResolverDefault());
|
||||||
|
Soup.Session.prototype.add_feature.call(_httpSession, new Soup.ProxyResolverDefault());
|
||||||
|
}
|
@ -3,19 +3,11 @@
|
|||||||
const Lang = imports.lang;
|
const Lang = imports.lang;
|
||||||
const Signals = imports.signals;
|
const Signals = imports.signals;
|
||||||
|
|
||||||
const Clutter = imports.gi.Clutter;
|
|
||||||
const GLib = imports.gi.GLib;
|
const GLib = imports.gi.GLib;
|
||||||
const Gio = imports.gi.Gio;
|
const Gio = imports.gi.Gio;
|
||||||
const St = imports.gi.St;
|
const St = imports.gi.St;
|
||||||
const Shell = imports.gi.Shell;
|
|
||||||
const Soup = imports.gi.Soup;
|
|
||||||
|
|
||||||
const Config = imports.misc.config;
|
|
||||||
const ExtensionUtils = imports.misc.extensionUtils;
|
const ExtensionUtils = imports.misc.extensionUtils;
|
||||||
const FileUtils = imports.misc.fileUtils;
|
|
||||||
const ModalDialog = imports.ui.modalDialog;
|
|
||||||
|
|
||||||
const API_VERSION = 1;
|
|
||||||
|
|
||||||
const ExtensionState = {
|
const ExtensionState = {
|
||||||
ENABLED: 1,
|
ENABLED: 1,
|
||||||
@ -30,12 +22,6 @@ const ExtensionState = {
|
|||||||
UNINSTALLED: 99
|
UNINSTALLED: 99
|
||||||
};
|
};
|
||||||
|
|
||||||
const REPOSITORY_URL_BASE = 'https://extensions.gnome.org';
|
|
||||||
const REPOSITORY_URL_DOWNLOAD = REPOSITORY_URL_BASE + '/download-extension/%s.shell-extension.zip';
|
|
||||||
const REPOSITORY_URL_INFO = REPOSITORY_URL_BASE + '/extension-info/';
|
|
||||||
|
|
||||||
let _httpSession;
|
|
||||||
|
|
||||||
// Arrays of uuids
|
// Arrays of uuids
|
||||||
var enabledExtensions;
|
var enabledExtensions;
|
||||||
// Contains the order that extensions were enabled in.
|
// Contains the order that extensions were enabled in.
|
||||||
@ -52,80 +38,6 @@ const disconnect = Lang.bind(_signals, _signals.disconnect);
|
|||||||
|
|
||||||
const ENABLED_EXTENSIONS_KEY = 'enabled-extensions';
|
const ENABLED_EXTENSIONS_KEY = 'enabled-extensions';
|
||||||
|
|
||||||
function installExtensionFromUUID(uuid) {
|
|
||||||
let params = { uuid: uuid,
|
|
||||||
shell_version: Config.PACKAGE_VERSION };
|
|
||||||
|
|
||||||
let message = Soup.form_request_new_from_hash('GET', REPOSITORY_URL_INFO, params);
|
|
||||||
|
|
||||||
_httpSession.queue_message(message,
|
|
||||||
function(session, message) {
|
|
||||||
let info = JSON.parse(message.response_body.data);
|
|
||||||
let dialog = new InstallExtensionDialog(uuid, info);
|
|
||||||
dialog.open(global.get_current_time());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uninstallExtensionFromUUID(uuid) {
|
|
||||||
let extension = ExtensionUtils.extensions[uuid];
|
|
||||||
if (!extension)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Try to disable it -- if it's ERROR'd, we can't guarantee that,
|
|
||||||
// but it will be removed on next reboot, and hopefully nothing
|
|
||||||
// broke too much.
|
|
||||||
disableExtension(uuid);
|
|
||||||
|
|
||||||
// Don't try to uninstall system extensions
|
|
||||||
if (extension.type != ExtensionUtils.ExtensionType.PER_USER)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
extension.state = ExtensionState.UNINSTALLED;
|
|
||||||
_signals.emit('extension-state-changed', extension);
|
|
||||||
|
|
||||||
delete ExtensionUtils.extensions[uuid];
|
|
||||||
|
|
||||||
FileUtils.recursivelyDeleteDir(Gio.file_new_for_path(extension.path));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gotExtensionZipFile(session, message, uuid) {
|
|
||||||
if (message.status_code != Soup.KnownStatusCode.OK) {
|
|
||||||
logExtensionError(uuid, 'downloading extension: ' + message.status_code);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let [file, stream] = Gio.File.new_tmp('XXXXXX.shell-extension.zip');
|
|
||||||
let dir = ExtensionUtils.userExtensionsDir.get_child(uuid);
|
|
||||||
let contents = message.response_body.flatten().as_bytes();
|
|
||||||
stream.output_stream.write_bytes(contents, null);
|
|
||||||
stream.close(null);
|
|
||||||
let [success, pid] = GLib.spawn_async(null,
|
|
||||||
['unzip', '-uod', dir.get_path(), '--', file.get_path()],
|
|
||||||
null,
|
|
||||||
GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD,
|
|
||||||
null);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
logExtensionError(uuid, 'extract: could not extract');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, function(pid, status) {
|
|
||||||
GLib.spawn_close_pid(pid);
|
|
||||||
|
|
||||||
// Add extension to 'enabled-extensions' for the user, always...
|
|
||||||
let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY);
|
|
||||||
if (enabledExtensions.indexOf(uuid) == -1) {
|
|
||||||
enabledExtensions.push(uuid);
|
|
||||||
global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadExtension(dir, ExtensionUtils.ExtensionType.PER_USER, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableExtension(uuid) {
|
function disableExtension(uuid) {
|
||||||
let extension = ExtensionUtils.extensions[uuid];
|
let extension = ExtensionUtils.extensions[uuid];
|
||||||
if (!extension)
|
if (!extension)
|
||||||
@ -347,11 +259,6 @@ function onEnabledExtensionsChanged() {
|
|||||||
function init() {
|
function init() {
|
||||||
ExtensionUtils.init();
|
ExtensionUtils.init();
|
||||||
|
|
||||||
_httpSession = new Soup.SessionAsync({ ssl_use_system_ca_file: true });
|
|
||||||
// See: https://bugzilla.gnome.org/show_bug.cgi?id=655189 for context.
|
|
||||||
// _httpSession.add_feature(new Soup.ProxyResolverDefault());
|
|
||||||
Soup.Session.prototype.add_feature.call(_httpSession, new Soup.ProxyResolverDefault());
|
|
||||||
|
|
||||||
global.settings.connect('changed::' + ENABLED_EXTENSIONS_KEY, onEnabledExtensionsChanged);
|
global.settings.connect('changed::' + ENABLED_EXTENSIONS_KEY, onEnabledExtensionsChanged);
|
||||||
enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY);
|
enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY);
|
||||||
}
|
}
|
||||||
@ -362,69 +269,3 @@ function loadExtensions() {
|
|||||||
loadExtension(dir, type, enabled);
|
loadExtension(dir, type, enabled);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstallExtensionDialog = new Lang.Class({
|
|
||||||
Name: 'InstallExtensionDialog',
|
|
||||||
Extends: ModalDialog.ModalDialog,
|
|
||||||
|
|
||||||
_init: function(uuid, info) {
|
|
||||||
this.parent({ styleClass: 'extension-dialog' });
|
|
||||||
|
|
||||||
this._uuid = uuid;
|
|
||||||
this._info = info;
|
|
||||||
|
|
||||||
this.setButtons([{ label: _("Cancel"),
|
|
||||||
action: Lang.bind(this, this._onCancelButtonPressed),
|
|
||||||
key: Clutter.Escape
|
|
||||||
},
|
|
||||||
{ label: _("Install"),
|
|
||||||
action: Lang.bind(this, this._onInstallButtonPressed)
|
|
||||||
}]);
|
|
||||||
|
|
||||||
let message = _("Download and install '%s' from extensions.gnome.org?").format(info.name);
|
|
||||||
|
|
||||||
let box = new St.BoxLayout();
|
|
||||||
this.contentLayout.add(box);
|
|
||||||
|
|
||||||
let gicon = new Gio.FileIcon({ file: Gio.File.new_for_uri(REPOSITORY_URL_BASE + info.icon) })
|
|
||||||
let icon = new St.Icon({ gicon: gicon });
|
|
||||||
box.add(icon);
|
|
||||||
|
|
||||||
let label = new St.Label({ text: message });
|
|
||||||
box.add(label);
|
|
||||||
},
|
|
||||||
|
|
||||||
_onCancelButtonPressed: function(button, event) {
|
|
||||||
this.close(global.get_current_time());
|
|
||||||
|
|
||||||
// Even though the extension is already "uninstalled", send through
|
|
||||||
// a state-changed signal for any users who want to know if the install
|
|
||||||
// went through correctly -- using proper async DBus would block more
|
|
||||||
// traditional clients like the plugin
|
|
||||||
let meta = { uuid: this._uuid,
|
|
||||||
state: ExtensionState.UNINSTALLED,
|
|
||||||
error: '' };
|
|
||||||
|
|
||||||
_signals.emit('extension-state-changed', meta);
|
|
||||||
},
|
|
||||||
|
|
||||||
_onInstallButtonPressed: function(button, event) {
|
|
||||||
let state = { uuid: this._uuid,
|
|
||||||
state: ExtensionState.DOWNLOADING,
|
|
||||||
error: '' };
|
|
||||||
|
|
||||||
_signals.emit('extension-state-changed', state);
|
|
||||||
|
|
||||||
let params = { shell_version: Config.PACKAGE_VERSION };
|
|
||||||
|
|
||||||
let url = REPOSITORY_URL_DOWNLOAD.format(this._uuid);
|
|
||||||
let message = Soup.form_request_new_from_hash('GET', url, params);
|
|
||||||
|
|
||||||
_httpSession.queue_message(message,
|
|
||||||
Lang.bind(this, function(session, message) {
|
|
||||||
gotExtensionZipFile(session, message, this._uuid);
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.close(global.get_current_time());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
@ -18,6 +18,7 @@ const PolkitAuthenticationAgent = imports.ui.polkitAuthenticationAgent;
|
|||||||
const KeyringPrompt = imports.ui.keyringPrompt;
|
const KeyringPrompt = imports.ui.keyringPrompt;
|
||||||
const Environment = imports.ui.environment;
|
const Environment = imports.ui.environment;
|
||||||
const ExtensionSystem = imports.ui.extensionSystem;
|
const ExtensionSystem = imports.ui.extensionSystem;
|
||||||
|
const ExtensionDownloader = imports.ui.extensionDownloader;
|
||||||
const Keyboard = imports.ui.keyboard;
|
const Keyboard = imports.ui.keyboard;
|
||||||
const MessageTray = imports.ui.messageTray;
|
const MessageTray = imports.ui.messageTray;
|
||||||
const Overview = imports.ui.overview;
|
const Overview = imports.ui.overview;
|
||||||
@ -221,6 +222,7 @@ function start() {
|
|||||||
|
|
||||||
if (sessionMode.allowExtensions) {
|
if (sessionMode.allowExtensions) {
|
||||||
ExtensionSystem.init();
|
ExtensionSystem.init();
|
||||||
|
ExtensionDownloader.init();
|
||||||
ExtensionSystem.loadExtensions();
|
ExtensionSystem.loadExtensions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ const Shell = imports.gi.Shell;
|
|||||||
|
|
||||||
const Config = imports.misc.config;
|
const Config = imports.misc.config;
|
||||||
const ExtensionSystem = imports.ui.extensionSystem;
|
const ExtensionSystem = imports.ui.extensionSystem;
|
||||||
|
const ExtensionDownloader = imports.ui.extensionDownloader;
|
||||||
const ExtensionUtils = imports.misc.extensionUtils;
|
const ExtensionUtils = imports.misc.extensionUtils;
|
||||||
const Flashspot = imports.ui.flashspot;
|
const Flashspot = imports.ui.flashspot;
|
||||||
const Main = imports.ui.main;
|
const Main = imports.ui.main;
|
||||||
@ -254,11 +255,11 @@ const GnomeShell = new Lang.Class({
|
|||||||
},
|
},
|
||||||
|
|
||||||
InstallRemoteExtension: function(uuid) {
|
InstallRemoteExtension: function(uuid) {
|
||||||
ExtensionSystem.installExtensionFromUUID(uuid);
|
ExtensionDownloader.installExtensionFromUUID(uuid);
|
||||||
},
|
},
|
||||||
|
|
||||||
UninstallExtension: function(uuid) {
|
UninstallExtension: function(uuid) {
|
||||||
return ExtensionSystem.uninstallExtensionFromUUID(uuid);
|
return ExtensionDownloader.uninstallExtensionFromUUID(uuid);
|
||||||
},
|
},
|
||||||
|
|
||||||
LaunchExtensionPrefs: function(uuid) {
|
LaunchExtensionPrefs: function(uuid) {
|
||||||
@ -279,8 +280,6 @@ const GnomeShell = new Lang.Class({
|
|||||||
Main.overview.hide();
|
Main.overview.hide();
|
||||||
},
|
},
|
||||||
|
|
||||||
ApiVersion: ExtensionSystem.API_VERSION,
|
|
||||||
|
|
||||||
ShellVersion: Config.PACKAGE_VERSION,
|
ShellVersion: Config.PACKAGE_VERSION,
|
||||||
|
|
||||||
_extensionStateChanged: function(_, newState) {
|
_extensionStateChanged: function(_, newState) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user