summaryrefslogtreecommitdiffstats
path: root/js/misc
diff options
context:
space:
mode:
Diffstat (limited to 'js/misc')
-rw-r--r--js/misc/config.js.in23
-rw-r--r--js/misc/dbusUtils.js68
-rw-r--r--js/misc/extensionUtils.js318
-rw-r--r--js/misc/fileUtils.js68
-rw-r--r--js/misc/gnomeSession.js45
-rw-r--r--js/misc/history.js114
-rw-r--r--js/misc/ibusManager.js398
-rw-r--r--js/misc/inputMethod.js386
-rw-r--r--js/misc/introspect.js217
-rw-r--r--js/misc/jsParse.js236
-rw-r--r--js/misc/keyboardManager.js163
-rw-r--r--js/misc/loginManager.js247
-rw-r--r--js/misc/meson.build15
-rw-r--r--js/misc/modemManager.js298
-rw-r--r--js/misc/objectManager.js261
-rw-r--r--js/misc/params.js28
-rw-r--r--js/misc/parentalControlsManager.js153
-rw-r--r--js/misc/permissionStore.js16
-rw-r--r--js/misc/signalTracker.js269
-rw-r--r--js/misc/signals.js22
-rw-r--r--js/misc/smartcardManager.js119
-rw-r--r--js/misc/systemActions.js474
-rw-r--r--js/misc/util.js617
-rw-r--r--js/misc/weather.js326
24 files changed, 4881 insertions, 0 deletions
diff --git a/js/misc/config.js.in b/js/misc/config.js.in
new file mode 100644
index 0000000..8ef4270
--- /dev/null
+++ b/js/misc/config.js.in
@@ -0,0 +1,23 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+const pkg = imports.package;
+
+/* The name of this package (not localized) */
+var PACKAGE_NAME = '@PACKAGE_NAME@';
+/* The version of this package */
+var PACKAGE_VERSION = '@PACKAGE_VERSION@';
+/* 1 if networkmanager is available, 0 otherwise */
+var HAVE_NETWORKMANAGER = @HAVE_NETWORKMANAGER@;
+/* 1 if soup2 should be used instead of soup3, 0 otherwise */
+var HAVE_SOUP2 = @HAVE_SOUP2@;
+/* gettext package */
+var GETTEXT_PACKAGE = '@GETTEXT_PACKAGE@';
+/* locale dir */
+var LOCALEDIR = '@datadir@/locale';
+/* other standard directories */
+var LIBEXECDIR = '@libexecdir@';
+var PKGDATADIR = '@datadir@/@PACKAGE_NAME@';
+/* g-i package versions */
+var LIBMUTTER_API_VERSION = '@LIBMUTTER_API_VERSION@'
+
+var HAVE_BLUETOOTH = pkg.checkSymbol('GnomeBluetooth', '3.0',
+ 'Client.default_adapter_state')
diff --git a/js/misc/dbusUtils.js b/js/misc/dbusUtils.js
new file mode 100644
index 0000000..ac26894
--- /dev/null
+++ b/js/misc/dbusUtils.js
@@ -0,0 +1,68 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported loadInterfaceXML, loadSubInterfaceXML */
+
+const Config = imports.misc.config;
+const { Gio, GLib } = imports.gi;
+
+let _ifaceResource = null;
+
+/**
+ * @private
+ */
+function _ensureIfaceResource() {
+ if (_ifaceResource)
+ return;
+
+ // don't use global.datadir so the method is usable from tests/tools
+ let dir = GLib.getenv('GNOME_SHELL_DATADIR') || Config.PKGDATADIR;
+ let path = `${dir}/gnome-shell-dbus-interfaces.gresource`;
+ _ifaceResource = Gio.Resource.load(path);
+ _ifaceResource._register();
+}
+
+/**
+ * @param {string} iface the interface name
+ * @returns {string | null} the XML string or null if it is not found
+ */
+function loadInterfaceXML(iface) {
+ _ensureIfaceResource();
+
+ let uri = `resource:///org/gnome/shell/dbus-interfaces/${iface}.xml`;
+ let f = Gio.File.new_for_uri(uri);
+
+ try {
+ let [ok_, bytes] = f.load_contents(null);
+ return new TextDecoder().decode(bytes);
+ } catch (e) {
+ log(`Failed to load D-Bus interface ${iface}`);
+ }
+
+ return null;
+}
+
+/**
+ * @param {string} iface the interface name
+ * @param {string} ifaceFile the interface filename
+ * @returns {string | null} the XML string or null if it is not found
+ */
+function loadSubInterfaceXML(iface, ifaceFile) {
+ let xml = loadInterfaceXML(ifaceFile);
+ if (!xml)
+ return null;
+
+ let ifaceStartTag = `<interface name="${iface}">`;
+ let ifaceStopTag = '</interface>';
+ let ifaceStartIndex = xml.indexOf(ifaceStartTag);
+ let ifaceEndIndex = xml.indexOf(ifaceStopTag, ifaceStartIndex + 1) + ifaceStopTag.length;
+
+ let xmlHeader = '<!DOCTYPE node PUBLIC\n' +
+ '\'-//freedesktop//DTD D-BUS Object Introspection 1.0//EN\'\n' +
+ '\'http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\'>\n' +
+ '<node>\n';
+ let xmlFooter = '</node>';
+
+ return (
+ xmlHeader +
+ xml.substr(ifaceStartIndex, ifaceEndIndex - ifaceStartIndex) +
+ xmlFooter);
+}
diff --git a/js/misc/extensionUtils.js b/js/misc/extensionUtils.js
new file mode 100644
index 0000000..3e89a87
--- /dev/null
+++ b/js/misc/extensionUtils.js
@@ -0,0 +1,318 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ExtensionState, ExtensionType, getCurrentExtension,
+ getSettings, initTranslations, gettext, ngettext, pgettext,
+ openPrefs, isOutOfDate, installImporter, serializeExtension,
+ deserializeExtension, setCurrentExtension */
+
+// Common utils for the extension system and the extension
+// preferences tool
+
+const { Gio, GLib } = imports.gi;
+
+const Gettext = imports.gettext;
+
+const Config = imports.misc.config;
+
+let Main = null;
+
+try {
+ Main = imports.ui.main;
+} catch (error) {
+ // Only log the error if it is not due to the
+ // missing import.
+ if (error?.name !== 'ImportError')
+ console.error(error);
+}
+
+let _extension = null;
+
+var ExtensionType = {
+ SYSTEM: 1,
+ PER_USER: 2,
+};
+
+var ExtensionState = {
+ ENABLED: 1,
+ DISABLED: 2,
+ ERROR: 3,
+ OUT_OF_DATE: 4,
+ DOWNLOADING: 5,
+ INITIALIZED: 6,
+
+ // Used as an error state for operations on unknown extensions,
+ // should never be in a real extensionMeta object.
+ UNINSTALLED: 99,
+};
+
+const SERIALIZED_PROPERTIES = [
+ 'type',
+ 'state',
+ 'path',
+ 'error',
+ 'hasPrefs',
+ 'hasUpdate',
+ 'canChange',
+];
+
+/**
+ * @param {object} extension the extension object to use in utilities like `initTranslations()`
+ */
+function setCurrentExtension(extension) {
+ if (Main)
+ throw new Error('setCurrentExtension() can only be called from outside the shell');
+
+ _extension = extension;
+}
+
+/**
+ * getCurrentExtension:
+ *
+ * @returns {?object} - The current extension, or null if not called from
+ * an extension.
+ */
+function getCurrentExtension() {
+ if (_extension)
+ return _extension;
+
+ let stack = new Error().stack.split('\n');
+ let extensionStackLine;
+
+ // Search for an occurrence of an extension stack frame
+ // Start at 1 because 0 is the stack frame of this function
+ for (let i = 1; i < stack.length; i++) {
+ if (stack[i].includes('/gnome-shell/extensions/')) {
+ extensionStackLine = stack[i];
+ break;
+ }
+ }
+ if (!extensionStackLine)
+ return null;
+
+ // The stack line is like:
+ // init([object Object])@/home/user/data/gnome-shell/extensions/u@u.id/prefs.js:8
+ //
+ // In the case that we're importing from
+ // module scope, the first field is blank:
+ // @/home/user/data/gnome-shell/extensions/u@u.id/prefs.js:8
+ let match = new RegExp('@(.+):\\d+').exec(extensionStackLine);
+ if (!match)
+ return null;
+
+ // local import, as the module is used from outside the gnome-shell process
+ // as well (not this function though)
+ let extensionManager = imports.ui.main.extensionManager;
+
+ let path = match[1];
+ let file = Gio.File.new_for_path(path);
+
+ // Walk up the directory tree, looking for an extension with
+ // the same UUID as a directory name.
+ while (file != null) {
+ let extension = extensionManager.lookup(file.get_basename());
+ if (extension !== undefined)
+ return extension;
+ file = file.get_parent();
+ }
+
+ return null;
+}
+
+/**
+ * initTranslations:
+ * @param {string=} domain - the gettext domain to use
+ *
+ * Initialize Gettext to load translations from extensionsdir/locale.
+ * If @domain is not provided, it will be taken from metadata['gettext-domain']
+ */
+function initTranslations(domain) {
+ let extension = getCurrentExtension();
+
+ if (!extension)
+ throw new Error('initTranslations() can only be called from extensions');
+
+ domain ||= extension.metadata['gettext-domain'];
+
+ // Expect USER extensions to have a locale/ subfolder, otherwise assume a
+ // SYSTEM extension that has been installed in the same prefix as the shell
+ let localeDir = extension.dir.get_child('locale');
+ if (localeDir.query_exists(null))
+ Gettext.bindtextdomain(domain, localeDir.get_path());
+ else
+ Gettext.bindtextdomain(domain, Config.LOCALEDIR);
+
+ Object.assign(extension, Gettext.domain(domain));
+}
+
+/**
+ * gettext:
+ * @param {string} str - the string to translate
+ *
+ * Translate @str using the extension's gettext domain
+ *
+ * @returns {string} - the translated string
+ *
+ */
+function gettext(str) {
+ return callExtensionGettextFunc('gettext', str);
+}
+
+/**
+ * ngettext:
+ * @param {string} str - the string to translate
+ * @param {string} strPlural - the plural form of the string
+ * @param {number} n - the quantity for which translation is needed
+ *
+ * Translate @str and choose plural form using the extension's
+ * gettext domain
+ *
+ * @returns {string} - the translated string
+ *
+ */
+function ngettext(str, strPlural, n) {
+ return callExtensionGettextFunc('ngettext', str, strPlural, n);
+}
+
+/**
+ * pgettext:
+ * @param {string} context - context to disambiguate @str
+ * @param {string} str - the string to translate
+ *
+ * Translate @str in the context of @context using the extension's
+ * gettext domain
+ *
+ * @returns {string} - the translated string
+ *
+ */
+function pgettext(context, str) {
+ return callExtensionGettextFunc('pgettext', context, str);
+}
+
+function callExtensionGettextFunc(func, ...args) {
+ const extension = getCurrentExtension();
+
+ if (!extension)
+ throw new Error(`${func}() can only be called from extensions`);
+
+ if (!extension[func])
+ throw new Error(`${func}() is used without calling initTranslations() first`);
+
+ return extension[func](...args);
+}
+
+/**
+ * getSettings:
+ * @param {string=} schema - the GSettings schema id
+ * @returns {Gio.Settings} - a new settings object for @schema
+ *
+ * Builds and returns a GSettings schema for @schema, using schema files
+ * in extensionsdir/schemas. If @schema is omitted, it is taken from
+ * metadata['settings-schema'].
+ */
+function getSettings(schema) {
+ let extension = getCurrentExtension();
+
+ if (!extension)
+ throw new Error('getSettings() can only be called from extensions');
+
+ schema ||= extension.metadata['settings-schema'];
+
+ const GioSSS = Gio.SettingsSchemaSource;
+
+ // Expect USER extensions to have a schemas/ subfolder, otherwise assume a
+ // SYSTEM extension that has been installed in the same prefix as the shell
+ let schemaDir = extension.dir.get_child('schemas');
+ let schemaSource;
+ if (schemaDir.query_exists(null)) {
+ schemaSource = GioSSS.new_from_directory(schemaDir.get_path(),
+ GioSSS.get_default(),
+ false);
+ } else {
+ schemaSource = GioSSS.get_default();
+ }
+
+ let schemaObj = schemaSource.lookup(schema, true);
+ if (!schemaObj)
+ throw new Error(`Schema ${schema} could not be found for extension ${extension.metadata.uuid}. Please check your installation`);
+
+ return new Gio.Settings({ settings_schema: schemaObj });
+}
+
+/**
+ * openPrefs:
+ *
+ * Open the preference dialog of the current extension
+ */
+function openPrefs() {
+ const extension = getCurrentExtension();
+
+ if (!extension)
+ throw new Error('openPrefs() can only be called from extensions');
+
+ try {
+ const extensionManager = imports.ui.main.extensionManager;
+ extensionManager.openExtensionPrefs(extension.uuid, '', {});
+ } catch (e) {
+ if (e.name === 'ImportError')
+ throw new Error('openPrefs() cannot be called from preferences');
+ logError(e, 'Failed to open extension preferences');
+ }
+}
+
+function isOutOfDate(extension) {
+ const [major] = Config.PACKAGE_VERSION.split('.');
+ return !extension.metadata['shell-version'].some(v => v.startsWith(major));
+}
+
+function serializeExtension(extension) {
+ let obj = { ...extension.metadata };
+
+ SERIALIZED_PROPERTIES.forEach(prop => {
+ obj[prop] = extension[prop];
+ });
+
+ let res = {};
+ for (let key in obj) {
+ let val = obj[key];
+ let type;
+ switch (typeof val) {
+ case 'string':
+ type = 's';
+ break;
+ case 'number':
+ type = 'd';
+ break;
+ case 'boolean':
+ type = 'b';
+ break;
+ default:
+ continue;
+ }
+ res[key] = GLib.Variant.new(type, val);
+ }
+
+ return res;
+}
+
+function deserializeExtension(variant) {
+ let res = { metadata: {} };
+ for (let prop in variant) {
+ let val = variant[prop].unpack();
+ if (SERIALIZED_PROPERTIES.includes(prop))
+ res[prop] = val;
+ else
+ res.metadata[prop] = val;
+ }
+ // add the 2 additional properties to create a valid extension object, as createExtensionObject()
+ res.uuid = res.metadata.uuid;
+ res.dir = Gio.File.new_for_path(res.path);
+ return res;
+}
+
+function installImporter(extension) {
+ let oldSearchPath = imports.searchPath.slice(); // make a copy
+ imports.searchPath = [extension.dir.get_parent().get_path()];
+ // importing a "subdir" creates a new importer object that doesn't affect
+ // the global one
+ extension.imports = imports[extension.uuid];
+ imports.searchPath = oldSearchPath;
+}
diff --git a/js/misc/fileUtils.js b/js/misc/fileUtils.js
new file mode 100644
index 0000000..a6bb182
--- /dev/null
+++ b/js/misc/fileUtils.js
@@ -0,0 +1,68 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported collectFromDatadirs, recursivelyDeleteDir,
+ recursivelyMoveDir, loadInterfaceXML, loadSubInterfaceXML */
+
+const { Gio, GLib } = imports.gi;
+
+var { loadInterfaceXML } = imports.misc.dbusUtils;
+
+function collectFromDatadirs(subdir, includeUserDir, processFile) {
+ let dataDirs = GLib.get_system_data_dirs();
+ if (includeUserDir)
+ dataDirs.unshift(GLib.get_user_data_dir());
+
+ for (let i = 0; i < dataDirs.length; i++) {
+ let path = GLib.build_filenamev([dataDirs[i], 'gnome-shell', subdir]);
+ let dir = Gio.File.new_for_path(path);
+
+ let fileEnum;
+ try {
+ fileEnum = dir.enumerate_children('standard::name,standard::type',
+ Gio.FileQueryInfoFlags.NONE, null);
+ } catch (e) {
+ fileEnum = null;
+ }
+ if (fileEnum != null) {
+ let info;
+ while ((info = fileEnum.next_file(null)))
+ processFile(fileEnum.get_child(info), info);
+ }
+ }
+}
+
+function recursivelyDeleteDir(dir, deleteParent) {
+ let children = dir.enumerate_children('standard::name,standard::type',
+ Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+
+ let info;
+ while ((info = children.next_file(null)) != null) {
+ let type = info.get_file_type();
+ let child = dir.get_child(info.get_name());
+ if (type == Gio.FileType.REGULAR)
+ child.delete(null);
+ else if (type == Gio.FileType.DIRECTORY)
+ recursivelyDeleteDir(child, true);
+ }
+
+ if (deleteParent)
+ dir.delete(null);
+}
+
+function recursivelyMoveDir(srcDir, destDir) {
+ let children = srcDir.enumerate_children('standard::name,standard::type',
+ Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+
+ if (!destDir.query_exists(null))
+ destDir.make_directory_with_parents(null);
+
+ let info;
+ while ((info = children.next_file(null)) != null) {
+ let type = info.get_file_type();
+ let srcChild = srcDir.get_child(info.get_name());
+ let destChild = destDir.get_child(info.get_name());
+ if (type == Gio.FileType.REGULAR)
+ srcChild.move(destChild, Gio.FileCopyFlags.NONE, null, null);
+ else if (type == Gio.FileType.DIRECTORY)
+ recursivelyMoveDir(srcChild, destChild);
+ }
+}
diff --git a/js/misc/gnomeSession.js b/js/misc/gnomeSession.js
new file mode 100644
index 0000000..487644f
--- /dev/null
+++ b/js/misc/gnomeSession.js
@@ -0,0 +1,45 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported PresenceStatus, Presence, Inhibitor, SessionManager, InhibitFlags */
+
+const Gio = imports.gi.Gio;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const PresenceIface = loadInterfaceXML('org.gnome.SessionManager.Presence');
+
+var PresenceStatus = {
+ AVAILABLE: 0,
+ INVISIBLE: 1,
+ BUSY: 2,
+ IDLE: 3,
+};
+
+var PresenceProxy = Gio.DBusProxy.makeProxyWrapper(PresenceIface);
+function Presence(initCallback, cancellable) {
+ return new PresenceProxy(Gio.DBus.session, 'org.gnome.SessionManager',
+ '/org/gnome/SessionManager/Presence', initCallback, cancellable);
+}
+
+// Note inhibitors are immutable objects, so they don't
+// change at runtime (changes always come in the form
+// of new inhibitors)
+const InhibitorIface = loadInterfaceXML('org.gnome.SessionManager.Inhibitor');
+var InhibitorProxy = Gio.DBusProxy.makeProxyWrapper(InhibitorIface);
+function Inhibitor(objectPath, initCallback, cancellable) {
+ return new InhibitorProxy(Gio.DBus.session, 'org.gnome.SessionManager', objectPath, initCallback, cancellable);
+}
+
+// Not the full interface, only the methods we use
+const SessionManagerIface = loadInterfaceXML('org.gnome.SessionManager');
+var SessionManagerProxy = Gio.DBusProxy.makeProxyWrapper(SessionManagerIface);
+function SessionManager(initCallback, cancellable) {
+ return new SessionManagerProxy(Gio.DBus.session, 'org.gnome.SessionManager', '/org/gnome/SessionManager', initCallback, cancellable);
+}
+
+var InhibitFlags = {
+ LOGOUT: 1 << 0,
+ SWITCH: 1 << 1,
+ SUSPEND: 1 << 2,
+ IDLE: 1 << 3,
+ AUTOMOUNT: 1 << 4,
+};
diff --git a/js/misc/history.js b/js/misc/history.js
new file mode 100644
index 0000000..268f13b
--- /dev/null
+++ b/js/misc/history.js
@@ -0,0 +1,114 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported HistoryManager */
+
+const Signals = imports.misc.signals;
+const Clutter = imports.gi.Clutter;
+const Params = imports.misc.params;
+
+var DEFAULT_LIMIT = 512;
+
+var HistoryManager = class extends Signals.EventEmitter {
+ constructor(params) {
+ super();
+
+ params = Params.parse(params, {
+ gsettingsKey: null,
+ limit: DEFAULT_LIMIT,
+ entry: null,
+ });
+
+ this._key = params.gsettingsKey;
+ this._limit = params.limit;
+
+ this._historyIndex = 0;
+ if (this._key) {
+ this._history = global.settings.get_strv(this._key);
+ global.settings.connect(`changed::${this._key}`,
+ this._historyChanged.bind(this));
+ } else {
+ this._history = [];
+ }
+
+ this._entry = params.entry;
+
+ if (this._entry) {
+ this._entry.connect('key-press-event',
+ this._onEntryKeyPress.bind(this));
+ }
+ }
+
+ _historyChanged() {
+ this._history = global.settings.get_strv(this._key);
+ this._historyIndex = this._history.length;
+ }
+
+ _setPrevItem(text) {
+ if (this._historyIndex <= 0)
+ return false;
+
+ if (text)
+ this._history[this._historyIndex] = text;
+ this._historyIndex--;
+ this._indexChanged();
+ return true;
+ }
+
+ _setNextItem(text) {
+ if (this._historyIndex >= this._history.length)
+ return false;
+
+ if (text)
+ this._history[this._historyIndex] = text;
+ this._historyIndex++;
+ this._indexChanged();
+ return true;
+ }
+
+ lastItem() {
+ if (this._historyIndex !== this._history.length) {
+ this._historyIndex = this._history.length;
+ this._indexChanged();
+ }
+
+ return this._historyIndex ? this._history[this._historyIndex - 1] : null;
+ }
+
+ addItem(input) {
+ input = input.trim();
+ if (input &&
+ (this._history.length === 0 ||
+ this._history[this._history.length - 1] !== input)) {
+ this._history = this._history.filter(entry => entry !== input);
+ this._history.push(input);
+ this._save();
+ }
+ this._historyIndex = this._history.length;
+ return input; // trimmed
+ }
+
+ _onEntryKeyPress(entry, event) {
+ let symbol = event.get_key_symbol();
+ if (symbol === Clutter.KEY_Up)
+ return this._setPrevItem(entry.get_text().trim());
+ else if (symbol === Clutter.KEY_Down)
+ return this._setNextItem(entry.get_text().trim());
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _indexChanged() {
+ let current = this._history[this._historyIndex] || '';
+ this.emit('changed', current);
+
+ if (this._entry)
+ this._entry.set_text(current);
+ }
+
+ _save() {
+ if (this._history.length > this._limit)
+ this._history.splice(0, this._history.length - this._limit);
+
+ if (this._key)
+ global.settings.set_strv(this._key, this._history);
+ }
+};
diff --git a/js/misc/ibusManager.js b/js/misc/ibusManager.js
new file mode 100644
index 0000000..214db55
--- /dev/null
+++ b/js/misc/ibusManager.js
@@ -0,0 +1,398 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported getIBusManager */
+
+const { Gio, GLib, IBus, Meta, Shell } = imports.gi;
+const Signals = imports.misc.signals;
+const BoxPointer = imports.ui.boxpointer;
+
+const IBusCandidatePopup = imports.ui.ibusCandidatePopup;
+
+Gio._promisify(IBus.Bus.prototype,
+ 'list_engines_async', 'list_engines_async_finish');
+Gio._promisify(IBus.Bus.prototype,
+ 'request_name_async', 'request_name_async_finish');
+Gio._promisify(IBus.Bus.prototype,
+ 'get_global_engine_async', 'get_global_engine_async_finish');
+Gio._promisify(IBus.Bus.prototype,
+ 'set_global_engine_async', 'set_global_engine_async_finish');
+Gio._promisify(Shell, 'util_systemd_unit_exists');
+
+// Ensure runtime version matches
+_checkIBusVersion(1, 5, 2);
+
+let _ibusManager = null;
+const IBUS_SYSTEMD_SERVICE = 'org.freedesktop.IBus.session.GNOME.service';
+
+const TYPING_BOOSTER_ENGINE = 'typing-booster';
+const IBUS_TYPING_BOOSTER_SCHEMA = 'org.freedesktop.ibus.engine.typing-booster';
+const KEY_EMOJIPREDICTIONS = 'emojipredictions';
+const KEY_DICTIONARY = 'dictionary';
+const KEY_INLINECOMPLETION = 'inlinecompletion';
+const KEY_INPUTMETHOD = 'inputmethod';
+
+function _checkIBusVersion(requiredMajor, requiredMinor, requiredMicro) {
+ if ((IBus.MAJOR_VERSION > requiredMajor) ||
+ (IBus.MAJOR_VERSION == requiredMajor && IBus.MINOR_VERSION > requiredMinor) ||
+ (IBus.MAJOR_VERSION == requiredMajor && IBus.MINOR_VERSION == requiredMinor &&
+ IBus.MICRO_VERSION >= requiredMicro))
+ return;
+
+ throw new Error(`Found IBus version ${
+ IBus.MAJOR_VERSION}.${IBus.MINOR_VERSION}.${IBus.MINOR_VERSION} ` +
+ `but required is ${requiredMajor}.${requiredMinor}.${requiredMicro}`);
+}
+
+function getIBusManager() {
+ if (_ibusManager == null)
+ _ibusManager = new IBusManager();
+ return _ibusManager;
+}
+
+var IBusManager = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ IBus.init();
+
+ // This is the longest we'll keep the keyboard frozen until an input
+ // source is active.
+ this._MAX_INPUT_SOURCE_ACTIVATION_TIME = 4000; // ms
+ this._PRELOAD_ENGINES_DELAY_TIME = 30; // sec
+
+
+ this._candidatePopup = new IBusCandidatePopup.CandidatePopup();
+
+ this._panelService = null;
+ this._engines = new Map();
+ this._ready = false;
+ this._registerPropertiesId = 0;
+ this._currentEngineName = null;
+ this._preloadEnginesId = 0;
+
+ this._ibus = IBus.Bus.new_async();
+ this._ibus.connect('connected', this._onConnected.bind(this));
+ this._ibus.connect('disconnected', this._clear.bind(this));
+ // Need to set this to get 'global-engine-changed' emitions
+ this._ibus.set_watch_ibus_signal(true);
+ this._ibus.connect('global-engine-changed', this._engineChanged.bind(this));
+
+ this._queueSpawn();
+ }
+
+ async _ibusSystemdServiceExists() {
+ if (this._ibusIsSystemdService)
+ return true;
+
+ try {
+ this._ibusIsSystemdService =
+ await Shell.util_systemd_unit_exists(
+ IBUS_SYSTEMD_SERVICE, null);
+ } catch (e) {
+ this._ibusIsSystemdService = false;
+ }
+
+ return this._ibusIsSystemdService;
+ }
+
+ async _queueSpawn() {
+ const isSystemdService = await this._ibusSystemdServiceExists();
+ if (!isSystemdService)
+ this._spawn(Meta.is_wayland_compositor() ? [] : ['--xim']);
+ }
+
+ _tryAppendEnv(env, varname) {
+ const value = GLib.getenv(varname);
+ if (value)
+ env.push(`${varname}=${value}`);
+ }
+
+ _spawn(extraArgs = []) {
+ try {
+ const cmdLine = ['ibus-daemon', '--panel', 'disable', ...extraArgs];
+ const launchContext = global.create_app_launch_context(0, -1);
+ const env = launchContext.get_environment();
+ // Use DO_NOT_REAP_CHILD to avoid adouble-fork internally
+ // since ibus-daemon refuses to start with init as its parent.
+ const [success_, pid] = GLib.spawn_async(
+ null, cmdLine, env,
+ GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD,
+ () => {
+ try {
+ global.context.restore_rlimit_nofile();
+ } catch (err) {
+ }
+ }
+ );
+ GLib.child_watch_add(
+ GLib.PRIORITY_DEFAULT,
+ pid,
+ () => GLib.spawn_close_pid(pid)
+ );
+ } catch (e) {
+ log(`Failed to launch ibus-daemon: ${e.message}`);
+ }
+ }
+
+ async restartDaemon(extraArgs = []) {
+ const isSystemdService = await this._ibusSystemdServiceExists();
+ if (!isSystemdService)
+ this._spawn(['-r', ...extraArgs]);
+ }
+
+ _clear() {
+ if (this._cancellable) {
+ this._cancellable.cancel();
+ this._cancellable = null;
+ }
+
+ if (this._preloadEnginesId) {
+ GLib.source_remove(this._preloadEnginesId);
+ this._preloadEnginesId = 0;
+ }
+
+ if (this._panelService)
+ this._panelService.destroy();
+
+ this._panelService = null;
+ this._candidatePopup.setPanelService(null);
+ this._engines.clear();
+ this._ready = false;
+ this._registerPropertiesId = 0;
+ this._currentEngineName = null;
+
+ this.emit('ready', false);
+ }
+
+ _onConnected() {
+ this._cancellable = new Gio.Cancellable();
+ this._initEngines();
+ this._initPanelService();
+ }
+
+ async _initEngines() {
+ try {
+ const enginesList =
+ await this._ibus.list_engines_async(-1, this._cancellable);
+ for (let i = 0; i < enginesList.length; ++i) {
+ let name = enginesList[i].get_name();
+ this._engines.set(name, enginesList[i]);
+ }
+ this._updateReadiness();
+ } catch (e) {
+ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ return;
+
+ logError(e);
+ this._clear();
+ }
+ }
+
+ async _initPanelService() {
+ try {
+ await this._ibus.request_name_async(IBus.SERVICE_PANEL,
+ IBus.BusNameFlag.REPLACE_EXISTING, -1, this._cancellable);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
+ logError(e);
+ this._clear();
+ }
+ return;
+ }
+
+ this._panelService = new IBus.PanelService({
+ connection: this._ibus.get_connection(),
+ object_path: IBus.PATH_PANEL,
+ });
+ this._candidatePopup.setPanelService(this._panelService);
+ this._panelService.connect('update-property', this._updateProperty.bind(this));
+ this._panelService.connect('set-cursor-location', (ps, x, y, w, h) => {
+ let cursorLocation = { x, y, width: w, height: h };
+ this.emit('set-cursor-location', cursorLocation);
+ });
+ this._panelService.connect('focus-in', (panel, path) => {
+ if (!GLib.str_has_suffix(path, '/InputContext_1'))
+ this.emit('focus-in');
+ });
+ this._panelService.connect('focus-out', () => this.emit('focus-out'));
+
+ try {
+ // IBus versions older than 1.5.10 have a bug which
+ // causes spurious set-content-type emissions when
+ // switching input focus that temporarily lose purpose
+ // and hints defeating its intended semantics and
+ // confusing users. We thus don't use it in that case.
+ _checkIBusVersion(1, 5, 10);
+ this._panelService.connect('set-content-type', this._setContentType.bind(this));
+ } catch (e) {
+ }
+ this._updateReadiness();
+
+ try {
+ // If an engine is already active we need to get its properties
+ const engine =
+ await this._ibus.get_global_engine_async(-1, this._cancellable);
+ this._engineChanged(this._ibus, engine.get_name());
+ } catch (e) {
+ }
+ }
+
+ _updateReadiness() {
+ this._ready = this._engines.size > 0 && this._panelService != null;
+ this.emit('ready', this._ready);
+ }
+
+ _engineChanged(bus, engineName) {
+ if (!this._ready)
+ return;
+
+ this._currentEngineName = engineName;
+ this._candidatePopup.close(BoxPointer.PopupAnimation.NONE);
+
+ if (this._registerPropertiesId != 0)
+ return;
+
+ this._registerPropertiesId =
+ this._panelService.connect('register-properties', (p, props) => {
+ if (!props.get(0))
+ return;
+
+ this._panelService.disconnect(this._registerPropertiesId);
+ this._registerPropertiesId = 0;
+
+ this.emit('properties-registered', this._currentEngineName, props);
+ });
+ }
+
+ _updateProperty(panel, prop) {
+ this.emit('property-updated', this._currentEngineName, prop);
+ }
+
+ _setContentType(panel, purpose, hints) {
+ this.emit('set-content-type', purpose, hints);
+ }
+
+ activateProperty(key, state) {
+ this._panelService.property_activate(key, state);
+ }
+
+ getEngineDesc(id) {
+ if (!this._ready || !this._engines.has(id))
+ return null;
+
+ return this._engines.get(id);
+ }
+
+ async _setEngine(id, callback) {
+ // Send id even if id == this._currentEngineName
+ // because 'properties-registered' signal can be emitted
+ // while this._ibusSources == null on a lock screen.
+ if (!this._ready) {
+ if (callback)
+ callback();
+ return;
+ }
+
+ try {
+ await this._ibus.set_global_engine_async(id,
+ this._MAX_INPUT_SOURCE_ACTIVATION_TIME,
+ this._cancellable);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ logError(e);
+ }
+
+ if (callback)
+ callback();
+ }
+
+ async setEngine(id, callback) {
+ if (this._preOskState)
+ this._preOskState.engine = id;
+
+ const isXkb = id.startsWith('xkb:');
+ if (this._oskCompletion && isXkb)
+ return;
+
+ if (this._oskCompletion)
+ this.setCompletionEnabled(false, callback);
+ else
+ await this._setEngine(id, callback);
+ }
+
+ preloadEngines(ids) {
+ if (!this._ibus || !this._ready)
+ return;
+
+ if (!ids.includes(TYPING_BOOSTER_ENGINE))
+ ids.push(TYPING_BOOSTER_ENGINE);
+
+ if (this._preloadEnginesId != 0) {
+ GLib.source_remove(this._preloadEnginesId);
+ this._preloadEnginesId = 0;
+ }
+
+ this._preloadEnginesId =
+ GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT,
+ this._PRELOAD_ENGINES_DELAY_TIME,
+ () => {
+ this._ibus.preload_engines_async(
+ ids,
+ -1,
+ this._cancellable,
+ null);
+ this._preloadEnginesId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ setCompletionEnabled(enabled, callback) {
+ /* Needs typing-booster available */
+ if (enabled && !this._engines.has(TYPING_BOOSTER_ENGINE))
+ return false;
+ /* Can do only on xkb engines */
+ if (enabled && !this._currentEngineName.startsWith('xkb:'))
+ return false;
+
+ if (this._oskCompletion === enabled)
+ return true;
+
+ this._oskCompletion = enabled;
+ let settings =
+ new Gio.Settings({schema_id: IBUS_TYPING_BOOSTER_SCHEMA});
+
+ if (enabled) {
+ this._preOskState = {
+ 'engine': this._currentEngineName,
+ 'emoji': settings.get_value(KEY_EMOJIPREDICTIONS),
+ 'langs': settings.get_value(KEY_DICTIONARY),
+ 'completion': settings.get_value(KEY_INLINECOMPLETION),
+ 'inputMethod': settings.get_value(KEY_INPUTMETHOD),
+ };
+ settings.reset(KEY_EMOJIPREDICTIONS);
+
+ const removeEncoding = l => l.replace(/\..*/, '');
+ const removeDups = (l, pos, arr) => {
+ return !pos || arr[pos - 1] !== l;
+ };
+ settings.set_string(
+ KEY_DICTIONARY,
+ GLib.get_language_names().map(removeEncoding)
+ .sort().filter(removeDups).join(','));
+
+ settings.reset(KEY_INLINECOMPLETION);
+ settings.set_string(KEY_INPUTMETHOD, 'NoIME');
+ this._setEngine(TYPING_BOOSTER_ENGINE, callback);
+ } else if (this._preOskState) {
+ const {engine, emoji, langs, completion, inputMethod} =
+ this._preOskState;
+ this._preOskState = null;
+ this._setEngine(engine, callback);
+ settings.set_value(KEY_EMOJIPREDICTIONS, emoji);
+ settings.set_value(KEY_DICTIONARY, langs);
+ settings.set_value(KEY_INLINECOMPLETION, completion);
+ settings.set_value(KEY_INPUTMETHOD, inputMethod);
+ }
+ return true;
+ }
+};
diff --git a/js/misc/inputMethod.js b/js/misc/inputMethod.js
new file mode 100644
index 0000000..e01eac8
--- /dev/null
+++ b/js/misc/inputMethod.js
@@ -0,0 +1,386 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported InputMethod */
+const { Clutter, GLib, Gio, GObject, IBus } = imports.gi;
+
+const Keyboard = imports.ui.status.keyboard;
+const Main = imports.ui.main;
+
+Gio._promisify(IBus.Bus.prototype,
+ 'create_input_context_async', 'create_input_context_async_finish');
+Gio._promisify(IBus.InputContext.prototype,
+ 'process_key_event_async', 'process_key_event_async_finish');
+
+var HIDE_PANEL_TIME = 50;
+
+const HAVE_REQUIRE_SURROUNDING_TEXT = GObject.signal_lookup('require-surrounding-text', IBus.InputContext);
+
+var InputMethod = GObject.registerClass({
+ Signals: {
+ 'surrounding-text-set': {},
+ 'terminal-mode-changed': {},
+ },
+}, class InputMethod extends Clutter.InputMethod {
+ _init() {
+ super._init();
+ this._hints = 0;
+ this._purpose = 0;
+ this._currentFocus = null;
+ this._preeditStr = '';
+ this._preeditPos = 0;
+ this._preeditAnchor = 0;
+ this._preeditVisible = false;
+ this._hidePanelId = 0;
+ this.terminalMode = false;
+ this._ibus = IBus.Bus.new_async();
+ this._ibus.connect('connected', this._onConnected.bind(this));
+ this._ibus.connect('disconnected', this._clear.bind(this));
+ this.connect('notify::can-show-preedit', this._updateCapabilities.bind(this));
+
+ this._inputSourceManager = Keyboard.getInputSourceManager();
+ this._sourceChangedId = this._inputSourceManager.connect('current-source-changed',
+ this._onSourceChanged.bind(this));
+ this._currentSource = this._inputSourceManager.currentSource;
+
+ if (this._ibus.is_connected())
+ this._onConnected();
+ }
+
+ get currentFocus() {
+ return this._currentFocus;
+ }
+
+ _updateCapabilities() {
+ let caps = IBus.Capabilite.PREEDIT_TEXT | IBus.Capabilite.FOCUS | IBus.Capabilite.SURROUNDING_TEXT;
+
+ if (Main.keyboard.visible)
+ caps |= IBus.Capabilite.OSK;
+
+ if (this._context)
+ this._context.set_capabilities(caps);
+ }
+
+ _onSourceChanged() {
+ this._currentSource = this._inputSourceManager.currentSource;
+ }
+
+ async _onConnected() {
+ this._cancellable = new Gio.Cancellable();
+ try {
+ this._context = await this._ibus.create_input_context_async(
+ 'gnome-shell', -1, this._cancellable);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
+ logError(e);
+ this._clear();
+ }
+ return;
+ }
+
+ this._context.set_client_commit_preedit(true);
+ this._context.connect('commit-text', this._onCommitText.bind(this));
+ this._context.connect('delete-surrounding-text', this._onDeleteSurroundingText.bind(this));
+ this._context.connect('update-preedit-text-with-mode', this._onUpdatePreeditText.bind(this));
+ this._context.connect('show-preedit-text', this._onShowPreeditText.bind(this));
+ this._context.connect('hide-preedit-text', this._onHidePreeditText.bind(this));
+ this._context.connect('forward-key-event', this._onForwardKeyEvent.bind(this));
+ this._context.connect('destroy', this._clear.bind(this));
+
+ if (HAVE_REQUIRE_SURROUNDING_TEXT)
+ this._context.connect('require-surrounding-text', this._onRequireSurroundingText.bind(this));
+
+ Main.keyboard.connectObject('visibility-changed', () => this._updateCapabilities());
+
+ this._updateCapabilities();
+ }
+
+ _clear() {
+ Main.keyboard.disconnectObject(this);
+
+ if (this._cancellable) {
+ this._cancellable.cancel();
+ this._cancellable = null;
+ }
+
+ this._context = null;
+ this._hints = 0;
+ this._purpose = 0;
+ this._preeditStr = '';
+ this._preeditPos = 0;
+ this._preeditAnchor = 0;
+ this._preeditVisible = false;
+ }
+
+ _emitRequestSurrounding() {
+ if (this._context.needs_surrounding_text())
+ this.emit('request-surrounding');
+ }
+
+ _onCommitText(_context, text) {
+ this.commit(text.get_text());
+ }
+
+ _onRequireSurroundingText(_context) {
+ this.request_surrounding();
+ }
+
+ _onDeleteSurroundingText(_context, offset, nchars) {
+ try {
+ this.delete_surrounding(offset, nchars);
+ } catch (e) {
+ // We may get out of bounds for negative offset on older mutter
+ this.delete_surrounding(0, nchars + offset);
+ }
+ }
+
+ _onUpdatePreeditText(_context, text, pos, visible, mode) {
+ if (text == null)
+ return;
+
+ let preedit = text.get_text();
+ if (preedit === '')
+ preedit = null;
+
+ const anchor = pos;
+ if (visible)
+ this.set_preedit_text(preedit, pos, anchor, mode);
+ else if (this._preeditVisible)
+ this.set_preedit_text(null, pos, anchor, mode);
+
+ this._preeditStr = preedit;
+ this._preeditPos = pos;
+ this._preeditAnchor = anchor;
+ this._preeditVisible = visible;
+ this._preeditCommitMode = mode;
+ }
+
+ _onShowPreeditText() {
+ this._preeditVisible = true;
+ this.set_preedit_text(
+ this._preeditStr, this._preeditPos, this._preeditAnchor,
+ this._preeditCommitMode);
+ }
+
+ _onHidePreeditText() {
+ this.set_preedit_text(
+ null, this._preeditPos, this._preeditAnchor,
+ this._preeditCommitMode);
+ this._preeditVisible = false;
+ }
+
+ _onForwardKeyEvent(_context, keyval, keycode, state) {
+ let press = (state & IBus.ModifierType.RELEASE_MASK) == 0;
+ state &= ~IBus.ModifierType.RELEASE_MASK;
+
+ let curEvent = Clutter.get_current_event();
+ let time;
+ if (curEvent)
+ time = curEvent.get_time();
+ else
+ time = global.display.get_current_time_roundtrip();
+
+ this.forward_key(keyval, keycode + 8, state & Clutter.ModifierType.MODIFIER_MASK, time, press);
+ }
+
+ vfunc_focus_in(focus) {
+ this._currentFocus = focus;
+ if (this._context) {
+ this.update();
+ this._context.focus_in();
+ this._emitRequestSurrounding();
+ }
+
+ if (this._hidePanelId) {
+ GLib.source_remove(this._hidePanelId);
+ this._hidePanelId = 0;
+ }
+ }
+
+ vfunc_focus_out() {
+ this._currentFocus = null;
+ if (this._context) {
+ this._fullReset();
+ this._context.focus_out();
+ }
+
+ if (this._preeditStr && this._preeditVisible) {
+ // Unset any preedit text
+ this.set_preedit_text(null, 0, 0, this._preeditCommitMode);
+ this._preeditStr = null;
+ }
+
+ this._hidePanelId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, HIDE_PANEL_TIME, () => {
+ this.set_input_panel_state(Clutter.InputPanelState.OFF);
+ this._hidePanelId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ vfunc_reset() {
+ if (this._context) {
+ this._context.reset();
+ this._emitRequestSurrounding();
+ }
+
+ this._surroundingText = null;
+ this._surroundingTextCursor = null;
+ this._preeditStr = null;
+ this._setTerminalMode(false);
+ }
+
+ vfunc_set_cursor_location(rect) {
+ if (this._context) {
+ this._cursorRect = {
+ x: rect.get_x(), y: rect.get_y(),
+ width: rect.get_width(), height: rect.get_height(),
+ };
+ this._context.set_cursor_location(
+ this._cursorRect.x, this._cursorRect.y,
+ this._cursorRect.width, this._cursorRect.height);
+ this._emitRequestSurrounding();
+ }
+ }
+
+ vfunc_set_surrounding(text, cursor, anchor) {
+ this._surroundingText = text;
+ this._surroundingTextCursor = cursor;
+ this.emit('surrounding-text-set');
+
+ if (!this._context || (!text && text !== ''))
+ return;
+
+ let ibusText = IBus.Text.new_from_string(text);
+ this._context.set_surrounding_text(ibusText, cursor, anchor);
+ }
+
+ vfunc_update_content_hints(hints) {
+ let ibusHints = 0;
+ if (hints & Clutter.InputContentHintFlags.COMPLETION)
+ ibusHints |= IBus.InputHints.WORD_COMPLETION;
+ if (hints & Clutter.InputContentHintFlags.SPELLCHECK)
+ ibusHints |= IBus.InputHints.SPELLCHECK;
+ if (hints & Clutter.InputContentHintFlags.AUTO_CAPITALIZATION)
+ ibusHints |= IBus.InputHints.UPPERCASE_SENTENCES;
+ if (hints & Clutter.InputContentHintFlags.LOWERCASE)
+ ibusHints |= IBus.InputHints.LOWERCASE;
+ if (hints & Clutter.InputContentHintFlags.UPPERCASE)
+ ibusHints |= IBus.InputHints.UPPERCASE_CHARS;
+ if (hints & Clutter.InputContentHintFlags.TITLECASE)
+ ibusHints |= IBus.InputHints.UPPERCASE_WORDS;
+
+ this._hints = ibusHints;
+ if (this._context)
+ this._context.set_content_type(this._purpose, this._hints);
+ }
+
+ vfunc_update_content_purpose(purpose) {
+ let ibusPurpose = 0;
+ if (purpose == Clutter.InputContentPurpose.NORMAL)
+ ibusPurpose = IBus.InputPurpose.FREE_FORM;
+ else if (purpose == Clutter.InputContentPurpose.ALPHA)
+ ibusPurpose = IBus.InputPurpose.ALPHA;
+ else if (purpose == Clutter.InputContentPurpose.DIGITS)
+ ibusPurpose = IBus.InputPurpose.DIGITS;
+ else if (purpose == Clutter.InputContentPurpose.NUMBER)
+ ibusPurpose = IBus.InputPurpose.NUMBER;
+ else if (purpose == Clutter.InputContentPurpose.PHONE)
+ ibusPurpose = IBus.InputPurpose.PHONE;
+ else if (purpose == Clutter.InputContentPurpose.URL)
+ ibusPurpose = IBus.InputPurpose.URL;
+ else if (purpose == Clutter.InputContentPurpose.EMAIL)
+ ibusPurpose = IBus.InputPurpose.EMAIL;
+ else if (purpose == Clutter.InputContentPurpose.NAME)
+ ibusPurpose = IBus.InputPurpose.NAME;
+ else if (purpose == Clutter.InputContentPurpose.PASSWORD)
+ ibusPurpose = IBus.InputPurpose.PASSWORD;
+ else if (purpose === Clutter.InputContentPurpose.TERMINAL &&
+ IBus.InputPurpose.TERMINAL)
+ ibusPurpose = IBus.InputPurpose.TERMINAL;
+
+ this._setTerminalMode(
+ purpose === Clutter.InputContentPurpose.TERMINAL);
+
+ this._purpose = ibusPurpose;
+ if (this._context)
+ this._context.set_content_type(this._purpose, this._hints);
+ }
+
+ _setTerminalMode(terminalMode) {
+ if (this.terminalMode !== terminalMode) {
+ this.terminalMode = terminalMode;
+ this.emit('terminal-mode-changed');
+ }
+ }
+
+ vfunc_filter_key_event(event) {
+ if (!this._context)
+ return false;
+ if (!this._currentSource)
+ return false;
+
+ let state = event.get_state();
+ if (state & IBus.ModifierType.IGNORED_MASK)
+ return false;
+
+ if (event.type() == Clutter.EventType.KEY_RELEASE)
+ state |= IBus.ModifierType.RELEASE_MASK;
+
+ this._context.process_key_event_async(
+ event.get_key_symbol(),
+ event.get_key_code() - 8, // Convert XKB keycodes to evcodes
+ state, -1, this._cancellable,
+ (context, res) => {
+ if (context != this._context)
+ return;
+
+ try {
+ let retval = context.process_key_event_async_finish(res);
+ this.notify_key_event(event, retval);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ log(`Error processing key on IM: ${e.message}`);
+ }
+ });
+ return true;
+ }
+
+ getSurroundingText() {
+ return [this._surroundingText, this._surroundingTextCursor];
+ }
+
+ hasPreedit() {
+ return this._preeditVisible && this._preeditStr !== '' && this._preeditStr !== null;
+ }
+
+ async handleVirtualKey(keyval) {
+ try {
+ if (!await this._context.process_key_event_async(
+ keyval, 0, 0, -1, null))
+ return false;
+
+ await this._context.process_key_event_async(
+ keyval, 0, IBus.ModifierType.RELEASE_MASK, -1, null);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ _fullReset() {
+ this._context.set_content_type(0, 0);
+ this._context.set_cursor_location(0, 0, 0, 0);
+ this._context.reset();
+ }
+
+ update() {
+ if (!this._context)
+ return;
+ this._updateCapabilities();
+ this._context.set_content_type(this._purpose, this._hints);
+ if (this._cursorRect) {
+ this._context.set_cursor_location(
+ this._cursorRect.x, this._cursorRect.y,
+ this._cursorRect.width, this._cursorRect.height);
+ }
+ this._emitRequestSurrounding();
+ }
+});
diff --git a/js/misc/introspect.js b/js/misc/introspect.js
new file mode 100644
index 0000000..8916804
--- /dev/null
+++ b/js/misc/introspect.js
@@ -0,0 +1,217 @@
+/* exported IntrospectService */
+const { Gio, GLib, Meta, Shell, St } = imports.gi;
+
+const APP_ALLOWLIST = [
+ 'org.freedesktop.impl.portal.desktop.gtk',
+ 'org.freedesktop.impl.portal.desktop.gnome',
+];
+
+const INTROSPECT_DBUS_API_VERSION = 3;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+const { DBusSenderChecker } = imports.misc.util;
+
+const IntrospectDBusIface = loadInterfaceXML('org.gnome.Shell.Introspect');
+
+var IntrospectService = class {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(IntrospectDBusIface,
+ this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Introspect');
+ Gio.DBus.session.own_name('org.gnome.Shell.Introspect',
+ Gio.BusNameOwnerFlags.REPLACE,
+ null, null);
+
+ this._runningApplications = {};
+ this._runningApplicationsDirty = true;
+ this._activeApplication = null;
+ this._activeApplicationDirty = true;
+ this._animationsEnabled = true;
+
+ this._appSystem = Shell.AppSystem.get_default();
+ this._appSystem.connect('app-state-changed',
+ () => {
+ this._runningApplicationsDirty = true;
+ this._syncRunningApplications();
+ });
+
+ let tracker = Shell.WindowTracker.get_default();
+ tracker.connect('notify::focus-app',
+ () => {
+ this._activeApplicationDirty = true;
+ this._syncRunningApplications();
+ });
+
+ tracker.connect('tracked-windows-changed',
+ () => this._dbusImpl.emit_signal('WindowsChanged', null));
+
+ this._syncRunningApplications();
+
+ this._senderChecker = new DBusSenderChecker(APP_ALLOWLIST);
+
+ this._settings = St.Settings.get();
+ this._settings.connect('notify::enable-animations',
+ this._syncAnimationsEnabled.bind(this));
+ this._syncAnimationsEnabled();
+
+ const monitorManager = Meta.MonitorManager.get();
+ monitorManager.connect('monitors-changed',
+ this._syncScreenSize.bind(this));
+ this._syncScreenSize();
+ }
+
+ _isStandaloneApp(app) {
+ return app.get_windows().some(w => w.transient_for == null);
+ }
+
+ _getSandboxedAppId(app) {
+ let ids = app.get_windows().map(w => w.get_sandboxed_app_id());
+ return ids.find(id => id != null);
+ }
+
+ _syncRunningApplications() {
+ let tracker = Shell.WindowTracker.get_default();
+ let apps = this._appSystem.get_running();
+ let seatName = "seat0";
+ let newRunningApplications = {};
+
+ let newActiveApplication = null;
+ let focusedApp = tracker.focus_app;
+
+ for (let app of apps) {
+ let appInfo = {};
+ let isAppActive = focusedApp == app;
+
+ if (!this._isStandaloneApp(app))
+ continue;
+
+ if (isAppActive) {
+ appInfo['active-on-seats'] = new GLib.Variant('as', [seatName]);
+ newActiveApplication = app.get_id();
+ }
+
+ let sandboxedAppId = this._getSandboxedAppId(app);
+ if (sandboxedAppId)
+ appInfo['sandboxed-app-id'] = new GLib.Variant('s', sandboxedAppId);
+
+ newRunningApplications[app.get_id()] = appInfo;
+ }
+
+ if (this._runningApplicationsDirty ||
+ (this._activeApplicationDirty &&
+ this._activeApplication != newActiveApplication)) {
+ this._runningApplications = newRunningApplications;
+ this._activeApplication = newActiveApplication;
+
+ this._dbusImpl.emit_signal('RunningApplicationsChanged', null);
+ }
+ this._runningApplicationsDirty = false;
+ this._activeApplicationDirty = false;
+ }
+
+ _isEligibleWindow(window) {
+ if (window.is_override_redirect())
+ return false;
+
+ let type = window.get_window_type();
+ return type == Meta.WindowType.NORMAL ||
+ type == Meta.WindowType.DIALOG ||
+ type == Meta.WindowType.MODAL_DIALOG ||
+ type == Meta.WindowType.UTILITY;
+ }
+
+ async GetRunningApplicationsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ invocation.return_value(new GLib.Variant('(a{sa{sv}})', [this._runningApplications]));
+ }
+
+ async GetWindowsAsync(params, invocation) {
+ let focusWindow = global.display.get_focus_window();
+ let apps = this._appSystem.get_running();
+ let windowsList = {};
+
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ for (let app of apps) {
+ let windows = app.get_windows();
+ for (let window of windows) {
+ if (!this._isEligibleWindow(window))
+ continue;
+
+ let windowId = window.get_id();
+ let frameRect = window.get_frame_rect();
+ let title = window.get_title();
+ let wmClass = window.get_wm_class();
+ let sandboxedAppId = window.get_sandboxed_app_id();
+
+ windowsList[windowId] = {
+ 'app-id': GLib.Variant.new('s', app.get_id()),
+ 'client-type': GLib.Variant.new('u', window.get_client_type()),
+ 'is-hidden': GLib.Variant.new('b', window.is_hidden()),
+ 'has-focus': GLib.Variant.new('b', window == focusWindow),
+ 'width': GLib.Variant.new('u', frameRect.width),
+ 'height': GLib.Variant.new('u', frameRect.height),
+ };
+
+ // These properties may not be available for all windows:
+ if (title != null)
+ windowsList[windowId]['title'] = GLib.Variant.new('s', title);
+
+ if (wmClass != null)
+ windowsList[windowId]['wm-class'] = GLib.Variant.new('s', wmClass);
+
+ if (sandboxedAppId != null) {
+ windowsList[windowId]['sandboxed-app-id'] =
+ GLib.Variant.new('s', sandboxedAppId);
+ }
+ }
+ }
+ invocation.return_value(new GLib.Variant('(a{ta{sv}})', [windowsList]));
+ }
+
+ _syncAnimationsEnabled() {
+ let wasAnimationsEnabled = this._animationsEnabled;
+ this._animationsEnabled = this._settings.enable_animations;
+ if (wasAnimationsEnabled !== this._animationsEnabled) {
+ let variant = new GLib.Variant('b', this._animationsEnabled);
+ this._dbusImpl.emit_property_changed('AnimationsEnabled', variant);
+ }
+ }
+
+ _syncScreenSize() {
+ const oldScreenWidth = this._screenWidth;
+ const oldScreenHeight = this._screenHeight;
+ this._screenWidth = global.screen_width;
+ this._screenHeight = global.screen_height;
+
+ if (oldScreenWidth !== this._screenWidth ||
+ oldScreenHeight !== this._screenHeight) {
+ const variant = new GLib.Variant('(ii)',
+ [this._screenWidth, this._screenHeight]);
+ this._dbusImpl.emit_property_changed('ScreenSize', variant);
+ }
+ }
+
+ get AnimationsEnabled() {
+ return this._animationsEnabled;
+ }
+
+ get ScreenSize() {
+ return [this._screenWidth, this._screenHeight];
+ }
+
+ get version() {
+ return INTROSPECT_DBUS_API_VERSION;
+ }
+};
diff --git a/js/misc/jsParse.js b/js/misc/jsParse.js
new file mode 100644
index 0000000..c4e077f
--- /dev/null
+++ b/js/misc/jsParse.js
@@ -0,0 +1,236 @@
+/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
+/* exported getCompletions, getCommonPrefix, getDeclaredConstants */
+
+// Returns a list of potential completions for text. Completions either
+// follow a dot (e.g. foo.ba -> bar) or they are picked from globalCompletionList (e.g. fo -> foo)
+// commandHeader is prefixed on any expression before it is eval'ed. It will most likely
+// consist of global constants that might not carry over from the calling environment.
+//
+// This function is likely the one you want to call from external modules
+function getCompletions(text, commandHeader, globalCompletionList) {
+ let methods = [];
+ let expr_, base;
+ let attrHead = '';
+ if (globalCompletionList == null)
+ globalCompletionList = [];
+
+ let offset = getExpressionOffset(text, text.length - 1);
+ if (offset >= 0) {
+ text = text.slice(offset);
+
+ // Look for expressions like "Main.panel.foo" and match Main.panel and foo
+ let matches = text.match(/(.*)\.(.*)/);
+ if (matches) {
+ [expr_, base, attrHead] = matches;
+
+ methods = getPropertyNamesFromExpression(base, commandHeader).filter(
+ attr => attr.slice(0, attrHead.length) === attrHead);
+ }
+
+ // Look for the empty expression or partially entered words
+ // not proceeded by a dot and match them against global constants
+ matches = text.match(/^(\w*)$/);
+ if (text == '' || matches) {
+ [expr_, attrHead] = matches;
+ methods = globalCompletionList.filter(
+ attr => attr.slice(0, attrHead.length) === attrHead);
+ }
+ }
+
+ return [methods, attrHead];
+}
+
+
+//
+// A few functions for parsing strings of javascript code.
+//
+
+// Identify characters that delimit an expression. That is,
+// if we encounter anything that isn't a letter, '.', ')', or ']',
+// we should stop parsing.
+function isStopChar(c) {
+ return !c.match(/[\w.)\]]/);
+}
+
+// Given the ending position of a quoted string, find where it starts
+function findMatchingQuote(expr, offset) {
+ let quoteChar = expr.charAt(offset);
+ for (let i = offset - 1; i >= 0; --i) {
+ if (expr.charAt(i) == quoteChar && expr.charAt(i - 1) != '\\')
+ return i;
+ }
+ return -1;
+}
+
+// Given the ending position of a regex, find where it starts
+function findMatchingSlash(expr, offset) {
+ for (let i = offset - 1; i >= 0; --i) {
+ if (expr.charAt(i) == '/' && expr.charAt(i - 1) != '\\')
+ return i;
+ }
+ return -1;
+}
+
+// If expr.charAt(offset) is ')' or ']',
+// return the position of the corresponding '(' or '[' bracket.
+// This function does not check for syntactic correctness. e.g.,
+// findMatchingBrace("[(])", 3) returns 1.
+function findMatchingBrace(expr, offset) {
+ let closeBrace = expr.charAt(offset);
+ let openBrace = { ')': '(', ']': '[' }[closeBrace];
+
+ return findTheBrace(expr, offset - 1, openBrace, closeBrace);
+}
+
+function findTheBrace(expr, offset, ...braces) {
+ let [openBrace, closeBrace] = braces;
+
+ if (offset < 0)
+ return -1;
+
+ if (expr.charAt(offset) == openBrace)
+ return offset;
+
+ if (expr.charAt(offset).match(/['"]/))
+ return findTheBrace(expr, findMatchingQuote(expr, offset) - 1, ...braces);
+
+ if (expr.charAt(offset) == '/')
+ return findTheBrace(expr, findMatchingSlash(expr, offset) - 1, ...braces);
+
+ if (expr.charAt(offset) == closeBrace)
+ return findTheBrace(expr, findTheBrace(expr, offset - 1, ...braces) - 1, ...braces);
+
+ return findTheBrace(expr, offset - 1, ...braces);
+}
+
+// Walk expr backwards from offset looking for the beginning of an
+// expression suitable for passing to eval.
+// There is no guarantee of correct javascript syntax between the return
+// value and offset. This function is meant to take a string like
+// "foo(Obj.We.Are.Completing" and allow you to extract "Obj.We.Are.Completing"
+function getExpressionOffset(expr, offset) {
+ while (offset >= 0) {
+ let currChar = expr.charAt(offset);
+
+ if (isStopChar(currChar))
+ return offset + 1;
+
+ if (currChar.match(/[)\]]/))
+ offset = findMatchingBrace(expr, offset);
+
+ --offset;
+ }
+
+ return offset + 1;
+}
+
+// Things with non-word characters or that start with a number
+// are not accessible via .foo notation and so aren't returned
+function isValidPropertyName(w) {
+ return !(w.match(/\W/) || w.match(/^\d/));
+}
+
+// To get all properties (enumerable and not), we need to walk
+// the prototype chain ourselves
+function getAllProps(obj) {
+ if (obj === null || obj === undefined)
+ return [];
+
+ return Object.getOwnPropertyNames(obj).concat(getAllProps(Object.getPrototypeOf(obj)));
+}
+
+// Given a string _expr_, returns all methods
+// that can be accessed via '.' notation.
+// e.g., expr="({ foo: null, bar: null, 4: null })" will
+// return ["foo", "bar", ...] but the list will not include "4",
+// since methods accessed with '.' notation must star with a letter or _.
+function getPropertyNamesFromExpression(expr, commandHeader = '') {
+ let obj = {};
+ if (!isUnsafeExpression(expr)) {
+ try {
+ obj = eval(commandHeader + expr);
+ } catch (e) {
+ return [];
+ }
+ } else {
+ return [];
+ }
+
+ let propsUnique = {};
+ if (typeof obj === 'object') {
+ let allProps = getAllProps(obj);
+ // Get only things we are allowed to complete following a '.'
+ allProps = allProps.filter(isValidPropertyName);
+
+ // Make sure propsUnique contains one key for every
+ // property so we end up with a unique list of properties
+ allProps.map(p => (propsUnique[p] = null));
+ }
+ return Object.keys(propsUnique).sort();
+}
+
+// Given a list of words, returns the longest prefix they all have in common
+function getCommonPrefix(words) {
+ let word = words[0];
+ for (let i = 0; i < word.length; i++) {
+ for (let w = 1; w < words.length; w++) {
+ if (words[w].charAt(i) != word.charAt(i))
+ return word.slice(0, i);
+ }
+ }
+ return word;
+}
+
+// Remove any blocks that are quoted or are in a regex
+function removeLiterals(str) {
+ if (str.length == 0)
+ return '';
+
+ let currChar = str.charAt(str.length - 1);
+ if (currChar == '"' || currChar == '\'') {
+ return removeLiterals(
+ str.slice(0, findMatchingQuote(str, str.length - 1)));
+ } else if (currChar == '/') {
+ return removeLiterals(
+ str.slice(0, findMatchingSlash(str, str.length - 1)));
+ }
+
+ return removeLiterals(str.slice(0, str.length - 1)) + currChar;
+}
+
+// Returns true if there is reason to think that eval(str)
+// will modify the global scope
+function isUnsafeExpression(str) {
+ // Check for any sort of assignment
+ // The strategy used is dumb: remove any quotes
+ // or regexs and comparison operators and see if there is an '=' character.
+ // If there is, it might be an unsafe assignment.
+
+ let prunedStr = removeLiterals(str);
+ prunedStr = prunedStr.replace(/[=!]==/g, ''); // replace === and !== with nothing
+ prunedStr = prunedStr.replace(/[=<>!]=/g, ''); // replace ==, <=, >=, != with nothing
+
+ if (prunedStr.match(/[=]/)) {
+ return true;
+ } else if (prunedStr.match(/;/)) {
+ // If we contain a semicolon not inside of a quote/regex, assume we're unsafe as well
+ return true;
+ }
+
+ return false;
+}
+
+// Returns a list of global keywords derived from str
+function getDeclaredConstants(str) {
+ let ret = [];
+ str.split(';').forEach(s => {
+ let base_, keyword;
+ let match = s.match(/const\s+(\w+)\s*=/);
+ if (match) {
+ [base_, keyword] = match;
+ ret.push(keyword);
+ }
+ });
+
+ return ret;
+}
diff --git a/js/misc/keyboardManager.js b/js/misc/keyboardManager.js
new file mode 100644
index 0000000..142e2f4
--- /dev/null
+++ b/js/misc/keyboardManager.js
@@ -0,0 +1,163 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported getKeyboardManager, holdKeyboard, releaseKeyboard */
+
+const { GLib, GnomeDesktop } = imports.gi;
+
+const Main = imports.ui.main;
+
+var DEFAULT_LOCALE = 'en_US';
+var DEFAULT_LAYOUT = 'us';
+var DEFAULT_VARIANT = '';
+
+let _xkbInfo = null;
+
+function getXkbInfo() {
+ if (_xkbInfo == null)
+ _xkbInfo = new GnomeDesktop.XkbInfo();
+ return _xkbInfo;
+}
+
+let _keyboardManager = null;
+
+function getKeyboardManager() {
+ if (_keyboardManager == null)
+ _keyboardManager = new KeyboardManager();
+ return _keyboardManager;
+}
+
+function releaseKeyboard() {
+ if (Main.modalCount > 0)
+ global.display.unfreeze_keyboard(global.get_current_time());
+ else
+ global.display.ungrab_keyboard(global.get_current_time());
+}
+
+function holdKeyboard() {
+ global.display.freeze_keyboard(global.get_current_time());
+}
+
+var KeyboardManager = class {
+ constructor() {
+ // The XKB protocol doesn't allow for more that 4 layouts in a
+ // keymap. Wayland doesn't impose this limit and libxkbcommon can
+ // handle up to 32 layouts but since we need to support X clients
+ // even as a Wayland compositor, we can't bump this.
+ this.MAX_LAYOUTS_PER_GROUP = 4;
+
+ this._xkbInfo = getXkbInfo();
+ this._current = null;
+ this._localeLayoutInfo = this._getLocaleLayout();
+ this._layoutInfos = {};
+ this._currentKeymap = null;
+ }
+
+ _applyLayoutGroup(group) {
+ let options = this._buildOptionsString();
+ let [layouts, variants] = this._buildGroupStrings(group);
+
+ if (this._currentKeymap &&
+ this._currentKeymap.layouts == layouts &&
+ this._currentKeymap.variants == variants &&
+ this._currentKeymap.options == options)
+ return;
+
+ this._currentKeymap = { layouts, variants, options };
+ global.backend.set_keymap(layouts, variants, options);
+ }
+
+ _applyLayoutGroupIndex(idx) {
+ global.backend.lock_layout_group(idx);
+ }
+
+ apply(id) {
+ let info = this._layoutInfos[id];
+ if (!info)
+ return;
+
+ if (this._current && this._current.group == info.group) {
+ if (this._current.groupIndex != info.groupIndex)
+ this._applyLayoutGroupIndex(info.groupIndex);
+ } else {
+ this._applyLayoutGroup(info.group);
+ this._applyLayoutGroupIndex(info.groupIndex);
+ }
+
+ this._current = info;
+ }
+
+ reapply() {
+ if (!this._current)
+ return;
+
+ this._applyLayoutGroup(this._current.group);
+ this._applyLayoutGroupIndex(this._current.groupIndex);
+ }
+
+ setUserLayouts(ids) {
+ this._current = null;
+ this._layoutInfos = {};
+
+ for (let i = 0; i < ids.length; ++i) {
+ let [found, , , _layout, _variant] = this._xkbInfo.get_layout_info(ids[i]);
+ if (found)
+ this._layoutInfos[ids[i]] = { id: ids[i], layout: _layout, variant: _variant };
+ }
+
+ let i = 0;
+ let group = [];
+ for (let id in this._layoutInfos) {
+ // We need to leave one slot on each group free so that we
+ // can add a layout containing the symbols for the
+ // language used in UI strings to ensure that toolkits can
+ // handle mnemonics like Alt+Š¤ even if the user is
+ // actually typing in a different layout.
+ let groupIndex = i % (this.MAX_LAYOUTS_PER_GROUP - 1);
+ if (groupIndex == 0)
+ group = [];
+
+ let info = this._layoutInfos[id];
+ group[groupIndex] = info;
+ info.group = group;
+ info.groupIndex = groupIndex;
+
+ i += 1;
+ }
+ }
+
+ _getLocaleLayout() {
+ let locale = GLib.get_language_names()[0];
+ if (!locale.includes('_'))
+ locale = DEFAULT_LOCALE;
+
+ let [found, , id] = GnomeDesktop.get_input_source_from_locale(locale);
+ if (!found)
+ [, , id] = GnomeDesktop.get_input_source_from_locale(DEFAULT_LOCALE);
+
+ let _layout, _variant;
+ [found, , , _layout, _variant] = this._xkbInfo.get_layout_info(id);
+ if (found)
+ return { layout: _layout, variant: _variant };
+ else
+ return { layout: DEFAULT_LAYOUT, variant: DEFAULT_VARIANT };
+ }
+
+ _buildGroupStrings(_group) {
+ let group = _group.concat(this._localeLayoutInfo);
+ let layouts = group.map(g => g.layout).join(',');
+ let variants = group.map(g => g.variant).join(',');
+ return [layouts, variants];
+ }
+
+ setKeyboardOptions(options) {
+ this._xkbOptions = options;
+ }
+
+ _buildOptionsString() {
+ let options = this._xkbOptions.join(',');
+ return options;
+ }
+
+ get currentLayout() {
+ return this._current;
+ }
+};
diff --git a/js/misc/loginManager.js b/js/misc/loginManager.js
new file mode 100644
index 0000000..94d62e8
--- /dev/null
+++ b/js/misc/loginManager.js
@@ -0,0 +1,247 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported canLock, getLoginManager, registerSessionWithGDM */
+
+const { GLib, Gio } = imports.gi;
+const Signals = imports.misc.signals;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const SystemdLoginManagerIface = loadInterfaceXML('org.freedesktop.login1.Manager');
+const SystemdLoginSessionIface = loadInterfaceXML('org.freedesktop.login1.Session');
+const SystemdLoginUserIface = loadInterfaceXML('org.freedesktop.login1.User');
+
+const SystemdLoginManager = Gio.DBusProxy.makeProxyWrapper(SystemdLoginManagerIface);
+const SystemdLoginSession = Gio.DBusProxy.makeProxyWrapper(SystemdLoginSessionIface);
+const SystemdLoginUser = Gio.DBusProxy.makeProxyWrapper(SystemdLoginUserIface);
+
+function haveSystemd() {
+ return GLib.access("/run/systemd/seats", 0) >= 0;
+}
+
+function versionCompare(required, reference) {
+ required = required.split('.');
+ reference = reference.split('.');
+
+ for (let i = 0; i < required.length; i++) {
+ let requiredInt = parseInt(required[i]);
+ let referenceInt = parseInt(reference[i]);
+ if (requiredInt != referenceInt)
+ return requiredInt < referenceInt;
+ }
+
+ return true;
+}
+
+function canLock() {
+ try {
+ let params = GLib.Variant.new('(ss)', ['org.gnome.DisplayManager.Manager', 'Version']);
+ let result = Gio.DBus.system.call_sync('org.gnome.DisplayManager',
+ '/org/gnome/DisplayManager/Manager',
+ 'org.freedesktop.DBus.Properties',
+ 'Get', params, null,
+ Gio.DBusCallFlags.NONE,
+ -1, null);
+
+ let version = result.deepUnpack()[0].deepUnpack();
+ return haveSystemd() && versionCompare('3.5.91', version);
+ } catch (e) {
+ return false;
+ }
+}
+
+
+async function registerSessionWithGDM() {
+ log("Registering session with GDM");
+ try {
+ await Gio.DBus.system.call(
+ 'org.gnome.DisplayManager',
+ '/org/gnome/DisplayManager/Manager',
+ 'org.gnome.DisplayManager.Manager',
+ 'RegisterSession',
+ GLib.Variant.new('(a{sv})', [{}]), null,
+ Gio.DBusCallFlags.NONE, -1, null);
+ } catch (e) {
+ if (!e.matches(Gio.DBusError, Gio.DBusError.UNKNOWN_METHOD))
+ log(`Error registering session with GDM: ${e.message}`);
+ else
+ log('Not calling RegisterSession(): method not exported, GDM too old?');
+ }
+}
+
+let _loginManager = null;
+
+/**
+ * getLoginManager:
+ * An abstraction over systemd/logind and ConsoleKit.
+ * @returns {object} - the LoginManager singleton
+ *
+ */
+function getLoginManager() {
+ if (_loginManager == null) {
+ if (haveSystemd())
+ _loginManager = new LoginManagerSystemd();
+ else
+ _loginManager = new LoginManagerDummy();
+ }
+
+ return _loginManager;
+}
+
+var LoginManagerSystemd = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ this._proxy = new SystemdLoginManager(Gio.DBus.system,
+ 'org.freedesktop.login1',
+ '/org/freedesktop/login1');
+ this._userProxy = new SystemdLoginUser(Gio.DBus.system,
+ 'org.freedesktop.login1',
+ '/org/freedesktop/login1/user/self');
+ this._proxy.connectSignal('PrepareForSleep',
+ this._prepareForSleep.bind(this));
+ }
+
+ async getCurrentSessionProxy() {
+ if (this._currentSession)
+ return this._currentSession;
+
+ let sessionId = GLib.getenv('XDG_SESSION_ID');
+ if (!sessionId) {
+ log('Unset XDG_SESSION_ID, getCurrentSessionProxy() called outside a user session. Asking logind directly.');
+ let [session, objectPath] = this._userProxy.Display;
+ if (session) {
+ log(`Will monitor session ${session}`);
+ sessionId = session;
+ } else {
+ log('Failed to find "Display" session; are we the greeter?');
+
+ for ([session, objectPath] of this._userProxy.Sessions) {
+ let sessionProxy = new SystemdLoginSession(Gio.DBus.system,
+ 'org.freedesktop.login1',
+ objectPath);
+ log(`Considering ${session}, class=${sessionProxy.Class}`);
+ if (sessionProxy.Class == 'greeter') {
+ log(`Yes, will monitor session ${session}`);
+ sessionId = session;
+ break;
+ }
+ }
+
+ if (!sessionId) {
+ log('No, failed to get session from logind.');
+ return null;
+ }
+ }
+ }
+
+ try {
+ const [objectPath] = await this._proxy.GetSessionAsync(sessionId);
+ this._currentSession = new SystemdLoginSession(Gio.DBus.system,
+ 'org.freedesktop.login1', objectPath);
+ return this._currentSession;
+ } catch (error) {
+ logError(error, 'Could not get a proxy for the current session');
+ return null;
+ }
+ }
+
+ async canSuspend() {
+ let canSuspend, needsAuth;
+
+ try {
+ const [result] = await this._proxy.CanSuspendAsync();
+ needsAuth = result === 'challenge';
+ canSuspend = needsAuth || result === 'yes';
+ } catch (error) {
+ canSuspend = false;
+ needsAuth = false;
+ }
+ return {canSuspend, needsAuth};
+ }
+
+ async canRebootToBootLoaderMenu() {
+ let canRebootToBootLoaderMenu, needsAuth;
+
+ try {
+ const [result] = await this._proxy.CanRebootToBootLoaderMenuAsync();
+ needsAuth = result === 'challenge';
+ canRebootToBootLoaderMenu = needsAuth || result === 'yes';
+ } catch (error) {
+ canRebootToBootLoaderMenu = false;
+ needsAuth = false;
+ }
+ return {canRebootToBootLoaderMenu, needsAuth};
+ }
+
+ setRebootToBootLoaderMenu() {
+ /* Parameter is timeout in usec, show to menu for 60 seconds */
+ this._proxy.SetRebootToBootLoaderMenuAsync(60000000);
+ }
+
+ async listSessions() {
+ try {
+ const [sessions] = await this._proxy.ListSessionsAsync();
+ return sessions;
+ } catch (e) {
+ return [];
+ }
+ }
+
+ suspend() {
+ this._proxy.SuspendAsync(true);
+ }
+
+ async inhibit(reason, cancellable) {
+ const inVariant = new GLib.Variant('(ssss)',
+ ['sleep', 'GNOME Shell', reason, 'delay']);
+ const [outVariant_, fdList] =
+ await this._proxy.call_with_unix_fd_list('Inhibit',
+ inVariant, 0, -1, null, cancellable);
+ const [fd] = fdList.steal_fds();
+ return new Gio.UnixInputStream({ fd });
+ }
+
+ _prepareForSleep(proxy, sender, [aboutToSuspend]) {
+ this.emit('prepare-for-sleep', aboutToSuspend);
+ }
+};
+
+var LoginManagerDummy = class extends Signals.EventEmitter {
+ getCurrentSessionProxy() {
+ // we could return a DummySession object that fakes whatever callers
+ // expect (at the time of writing: connect() and connectSignal()
+ // methods), but just never settling the promise should be safer
+ return new Promise(() => {});
+ }
+
+ canSuspend() {
+ return new Promise(resolve => resolve({
+ canSuspend: false,
+ needsAuth: false,
+ }));
+ }
+
+ canRebootToBootLoaderMenu() {
+ return new Promise(resolve => resolve({
+ canRebootToBootLoaderMenu: false,
+ needsAuth: false,
+ }));
+ }
+
+ setRebootToBootLoaderMenu() {
+ }
+
+ listSessions() {
+ return new Promise(resolve => resolve([]));
+ }
+
+ suspend() {
+ this.emit('prepare-for-sleep', true);
+ this.emit('prepare-for-sleep', false);
+ }
+
+ /* eslint-disable-next-line require-await */
+ async inhibit() {
+ return null;
+ }
+};
diff --git a/js/misc/meson.build b/js/misc/meson.build
new file mode 100644
index 0000000..2dff20d
--- /dev/null
+++ b/js/misc/meson.build
@@ -0,0 +1,15 @@
+jsconf = configuration_data()
+jsconf.set('PACKAGE_NAME', meson.project_name())
+jsconf.set('PACKAGE_VERSION', meson.project_version())
+jsconf.set('GETTEXT_PACKAGE', meson.project_name())
+jsconf.set('LIBMUTTER_API_VERSION', mutter_api_version)
+jsconf.set10('HAVE_NETWORKMANAGER', have_networkmanager)
+jsconf.set10('HAVE_SOUP2', have_soup2)
+jsconf.set('datadir', datadir)
+jsconf.set('libexecdir', libexecdir)
+
+config_js = configure_file(
+ input: 'config.js.in',
+ output: 'config.js',
+ configuration: jsconf
+)
diff --git a/js/misc/modemManager.js b/js/misc/modemManager.js
new file mode 100644
index 0000000..3c29795
--- /dev/null
+++ b/js/misc/modemManager.js
@@ -0,0 +1,298 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ModemBase, ModemGsm, ModemCdma, BroadbandModem */
+
+const { Gio, GObject, NM, NMA } = imports.gi;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+// _getMobileProvidersDatabase:
+//
+// Gets the database of mobile providers, with references between MCCMNC/SID and
+// operator name
+//
+let _mpd;
+function _getMobileProvidersDatabase() {
+ if (_mpd == null) {
+ try {
+ _mpd = new NMA.MobileProvidersDatabase();
+ _mpd.init(null);
+ } catch (e) {
+ log(e.message);
+ _mpd = null;
+ }
+ }
+
+ return _mpd;
+}
+
+// _findProviderForMccMnc:
+// @operatorName: operator name
+// @operatorCode: operator code
+//
+// Given an operator name string (which may not be a real operator name) and an
+// operator code string, tries to find a proper operator name to display.
+//
+function _findProviderForMccMnc(operatorName, operatorCode) {
+ if (operatorName) {
+ if (operatorName.length != 0 &&
+ (operatorName.length > 6 || operatorName.length < 5)) {
+ // this looks like a valid name, i.e. not an MCCMNC (that some
+ // devices return when not yet connected
+ return operatorName;
+ }
+
+ if (isNaN(parseInt(operatorName))) {
+ // name is definitely not a MCCMNC, so it may be a name
+ // after all; return that
+ return operatorName;
+ }
+ }
+
+ let needle;
+ if ((!operatorName || operatorName.length == 0) && operatorCode)
+ needle = operatorCode;
+ else if (operatorName && (operatorName.length == 6 || operatorName.length == 5))
+ needle = operatorName;
+ else // nothing to search
+ return null;
+
+ let mpd = _getMobileProvidersDatabase();
+ if (mpd) {
+ let provider = mpd.lookup_3gpp_mcc_mnc(needle);
+ if (provider)
+ return provider.get_name();
+ }
+ return null;
+}
+
+// _findProviderForSid:
+// @sid: System Identifier of the serving CDMA network
+//
+// Tries to find the operator name corresponding to the given SID
+//
+function _findProviderForSid(sid) {
+ if (!sid)
+ return null;
+
+ let mpd = _getMobileProvidersDatabase();
+ if (mpd) {
+ let provider = mpd.lookup_cdma_sid(sid);
+ if (provider)
+ return provider.get_name();
+ }
+ return null;
+}
+
+
+// ----------------------------------------------------- //
+// Support for the old ModemManager interface (MM < 0.7) //
+// ----------------------------------------------------- //
+
+
+// The following are not the complete interfaces, just the methods we need
+// (or may need in the future)
+
+const ModemGsmNetworkInterface = loadInterfaceXML('org.freedesktop.ModemManager.Modem.Gsm.Network');
+const ModemGsmNetworkProxy = Gio.DBusProxy.makeProxyWrapper(ModemGsmNetworkInterface);
+
+const ModemCdmaInterface = loadInterfaceXML('org.freedesktop.ModemManager.Modem.Cdma');
+const ModemCdmaProxy = Gio.DBusProxy.makeProxyWrapper(ModemCdmaInterface);
+
+var ModemBase = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'operator-name': GObject.ParamSpec.string(
+ 'operator-name', 'operator-name', 'operator-name',
+ GObject.ParamFlags.READABLE,
+ null),
+ 'signal-quality': GObject.ParamSpec.int(
+ 'signal-quality', 'signal-quality', 'signal-quality',
+ GObject.ParamFlags.READABLE,
+ 0, 100, 0),
+ },
+}, class ModemBase extends GObject.Object {
+ _init() {
+ super._init();
+ this._operatorName = null;
+ this._signalQuality = 0;
+ }
+
+ get operatorName() {
+ return this._operatorName;
+ }
+
+ get signalQuality() {
+ return this._signalQuality;
+ }
+
+ _setOperatorName(operatorName) {
+ if (this._operatorName == operatorName)
+ return;
+ this._operatorName = operatorName;
+ this.notify('operator-name');
+ }
+
+ _setSignalQuality(signalQuality) {
+ if (this._signalQuality == signalQuality)
+ return;
+ this._signalQuality = signalQuality;
+ this.notify('signal-quality');
+ }
+});
+
+var ModemGsm = GObject.registerClass(
+class ModemGsm extends ModemBase {
+ _init(path) {
+ super._init();
+ this._proxy = new ModemGsmNetworkProxy(Gio.DBus.system, 'org.freedesktop.ModemManager', path);
+
+ // Code is duplicated because the function have different signatures
+ this._proxy.connectSignal('SignalQuality', (proxy, sender, [quality]) => {
+ this._setSignalQuality(quality);
+ });
+ this._proxy.connectSignal('RegistrationInfo', (proxy, sender, [_status, code, name]) => {
+ this._setOperatorName(_findProviderForMccMnc(name, code));
+ });
+ this._getInitialState();
+ }
+
+ async _getInitialState() {
+ try {
+ const [
+ [status_, code, name],
+ [quality],
+ ] = await Promise.all([
+ this._proxy.GetRegistrationInfoAsync(),
+ this._proxy.GetSignalQualityAsync(),
+ ]);
+ this._setOperatorName(_findProviderForMccMnc(name, code));
+ this._setSignalQuality(quality);
+ } catch (err) {
+ // it will return an error if the device is not connected
+ this._setSignalQuality(0);
+ }
+ }
+});
+
+var ModemCdma = GObject.registerClass(
+class ModemCdma extends ModemBase {
+ _init(path) {
+ super._init();
+ this._proxy = new ModemCdmaProxy(Gio.DBus.system, 'org.freedesktop.ModemManager', path);
+
+ this._proxy.connectSignal('SignalQuality', (proxy, sender, params) => {
+ this._setSignalQuality(params[0]);
+
+ // receiving this signal means the device got activated
+ // and we can finally call GetServingSystem
+ if (this.operator_name == null)
+ this._refreshServingSystem();
+ });
+ this._getSignalQuality();
+ }
+
+ async _getSignalQuality() {
+ try {
+ const [quality] = await this._proxy.GetSignalQualityAsync();
+ this._setSignalQuality(quality);
+ } catch (err) {
+ // it will return an error if the device is not connected
+ this._setSignalQuality(0);
+ }
+ }
+
+ async _refreshServingSystem() {
+ try {
+ const [bandClass_, band_, sid] =
+ await this._proxy.GetServingSystemAsync();
+ this._setOperatorName(_findProviderForSid(sid));
+ } catch (err) {
+ // it will return an error if the device is not connected
+ this._setOperatorName(null);
+ }
+ }
+});
+
+
+// ------------------------------------------------------- //
+// Support for the new ModemManager1 interface (MM >= 0.7) //
+// ------------------------------------------------------- //
+
+const BroadbandModemInterface = loadInterfaceXML('org.freedesktop.ModemManager1.Modem');
+const BroadbandModemProxy = Gio.DBusProxy.makeProxyWrapper(BroadbandModemInterface);
+
+const BroadbandModem3gppInterface = loadInterfaceXML('org.freedesktop.ModemManager1.Modem.Modem3gpp');
+const BroadbandModem3gppProxy = Gio.DBusProxy.makeProxyWrapper(BroadbandModem3gppInterface);
+
+const BroadbandModemCdmaInterface = loadInterfaceXML('org.freedesktop.ModemManager1.Modem.ModemCdma');
+const BroadbandModemCdmaProxy = Gio.DBusProxy.makeProxyWrapper(BroadbandModemCdmaInterface);
+
+var BroadbandModem = GObject.registerClass({
+ Properties: {
+ 'capabilities': GObject.ParamSpec.flags(
+ 'capabilities', 'capabilities', 'capabilities',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ NM.DeviceModemCapabilities.$gtype,
+ NM.DeviceModemCapabilities.NONE),
+ },
+}, class BroadbandModem extends ModemBase {
+ _init(path, capabilities) {
+ super._init({ capabilities });
+ this._proxy = new BroadbandModemProxy(Gio.DBus.system, 'org.freedesktop.ModemManager1', path);
+ this._proxy_3gpp = new BroadbandModem3gppProxy(Gio.DBus.system, 'org.freedesktop.ModemManager1', path);
+ this._proxy_cdma = new BroadbandModemCdmaProxy(Gio.DBus.system, 'org.freedesktop.ModemManager1', path);
+
+ this._proxy.connect('g-properties-changed', (proxy, properties) => {
+ const signalQualityChanged = !!properties.lookup_value('SignalQuality', null);
+ if (signalQualityChanged)
+ this._reloadSignalQuality();
+ });
+ this._reloadSignalQuality();
+
+ this._proxy_3gpp.connect('g-properties-changed', (proxy, properties) => {
+ let unpacked = properties.deepUnpack();
+ if ('OperatorName' in unpacked || 'OperatorCode' in unpacked)
+ this._reload3gppOperatorName();
+ });
+ this._reload3gppOperatorName();
+
+ this._proxy_cdma.connect('g-properties-changed', (proxy, properties) => {
+ let unpacked = properties.deepUnpack();
+ if ('Nid' in unpacked || 'Sid' in unpacked)
+ this._reloadCdmaOperatorName();
+ });
+ this._reloadCdmaOperatorName();
+ }
+
+ _reloadSignalQuality() {
+ let [quality, recent_] = this._proxy.SignalQuality;
+ this._setSignalQuality(quality);
+ }
+
+ _reloadOperatorName() {
+ let newName = "";
+ if (this.operator_name_3gpp && this.operator_name_3gpp.length > 0)
+ newName += this.operator_name_3gpp;
+
+ if (this.operator_name_cdma && this.operator_name_cdma.length > 0) {
+ if (newName != "")
+ newName += ", ";
+ newName += this.operator_name_cdma;
+ }
+
+ this._setOperatorName(newName);
+ }
+
+ _reload3gppOperatorName() {
+ let name = this._proxy_3gpp.OperatorName;
+ let code = this._proxy_3gpp.OperatorCode;
+ this.operator_name_3gpp = _findProviderForMccMnc(name, code);
+ this._reloadOperatorName();
+ }
+
+ _reloadCdmaOperatorName() {
+ let sid = this._proxy_cdma.Sid;
+ this.operator_name_cdma = _findProviderForSid(sid);
+ this._reloadOperatorName();
+ }
+});
diff --git a/js/misc/objectManager.js b/js/misc/objectManager.js
new file mode 100644
index 0000000..a1dcde3
--- /dev/null
+++ b/js/misc/objectManager.js
@@ -0,0 +1,261 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ObjectManager */
+
+const { Gio, GLib } = imports.gi;
+const Params = imports.misc.params;
+const Signals = imports.misc.signals;
+
+// Specified in the D-Bus specification here:
+// http://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces-objectmanager
+const ObjectManagerIface = `
+<node>
+<interface name="org.freedesktop.DBus.ObjectManager">
+ <method name="GetManagedObjects">
+ <arg name="objects" type="a{oa{sa{sv}}}" direction="out"/>
+ </method>
+ <signal name="InterfacesAdded">
+ <arg name="objectPath" type="o"/>
+ <arg name="interfaces" type="a{sa{sv}}" />
+ </signal>
+ <signal name="InterfacesRemoved">
+ <arg name="objectPath" type="o"/>
+ <arg name="interfaces" type="as" />
+ </signal>
+</interface>
+</node>`;
+
+const ObjectManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(ObjectManagerIface);
+
+var ObjectManager = class extends Signals.EventEmitter {
+ constructor(params) {
+ super();
+
+ params = Params.parse(params, {
+ connection: null,
+ name: null,
+ objectPath: null,
+ knownInterfaces: null,
+ cancellable: null,
+ onLoaded: null,
+ });
+
+ this._connection = params.connection;
+ this._serviceName = params.name;
+ this._managerPath = params.objectPath;
+ this._cancellable = params.cancellable;
+
+ this._managerProxy = new Gio.DBusProxy({
+ g_connection: this._connection,
+ g_interface_name: ObjectManagerInfo.name,
+ g_interface_info: ObjectManagerInfo,
+ g_name: this._serviceName,
+ g_object_path: this._managerPath,
+ g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START,
+ });
+
+ this._interfaceInfos = {};
+ this._objects = {};
+ this._interfaces = {};
+ this._onLoaded = params.onLoaded;
+
+ if (params.knownInterfaces)
+ this._registerInterfaces(params.knownInterfaces);
+
+ this._initManagerProxy();
+ }
+
+ _completeLoad() {
+ if (this._onLoaded)
+ this._onLoaded();
+ }
+
+ async _addInterface(objectPath, interfaceName) {
+ let info = this._interfaceInfos[interfaceName];
+
+ if (!info)
+ return;
+
+ const proxy = new Gio.DBusProxy({
+ g_connection: this._connection,
+ g_name: this._serviceName,
+ g_object_path: objectPath,
+ g_interface_name: interfaceName,
+ g_interface_info: info,
+ g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START,
+ });
+
+ try {
+ await proxy.init_async(GLib.PRIORITY_DEFAULT, this._cancellable);
+ } catch (e) {
+ logError(e, `could not initialize proxy for interface ${interfaceName}`);
+ return;
+ }
+
+ let isNewObject;
+ if (!this._objects[objectPath]) {
+ this._objects[objectPath] = {};
+ isNewObject = true;
+ } else {
+ isNewObject = false;
+ }
+
+ this._objects[objectPath][interfaceName] = proxy;
+
+ if (!this._interfaces[interfaceName])
+ this._interfaces[interfaceName] = [];
+
+ this._interfaces[interfaceName].push(proxy);
+
+ if (isNewObject)
+ this.emit('object-added', objectPath);
+
+ this.emit('interface-added', interfaceName, proxy);
+ }
+
+ _removeInterface(objectPath, interfaceName) {
+ if (!this._objects[objectPath])
+ return;
+
+ let proxy = this._objects[objectPath][interfaceName];
+
+ if (this._interfaces[interfaceName]) {
+ let index = this._interfaces[interfaceName].indexOf(proxy);
+
+ if (index >= 0)
+ this._interfaces[interfaceName].splice(index, 1);
+
+ if (this._interfaces[interfaceName].length === 0)
+ delete this._interfaces[interfaceName];
+ }
+
+ this.emit('interface-removed', interfaceName, proxy);
+
+ delete this._objects[objectPath][interfaceName];
+
+ if (Object.keys(this._objects[objectPath]).length === 0) {
+ delete this._objects[objectPath];
+ this.emit('object-removed', objectPath);
+ }
+ }
+
+ async _initManagerProxy() {
+ try {
+ await this._managerProxy.init_async(
+ GLib.PRIORITY_DEFAULT, this._cancellable);
+ } catch (e) {
+ logError(e, `could not initialize object manager for object ${this._serviceName}`);
+
+ this._completeLoad();
+ return;
+ }
+
+ this._managerProxy.connectSignal('InterfacesAdded',
+ (objectManager, sender, [objectPath, interfaces]) => {
+ let interfaceNames = Object.keys(interfaces);
+ for (let i = 0; i < interfaceNames.length; i++)
+ this._addInterface(objectPath, interfaceNames[i]);
+ });
+ this._managerProxy.connectSignal('InterfacesRemoved',
+ (objectManager, sender, [objectPath, interfaceNames]) => {
+ for (let i = 0; i < interfaceNames.length; i++)
+ this._removeInterface(objectPath, interfaceNames[i]);
+ });
+
+ if (Object.keys(this._interfaceInfos).length === 0) {
+ this._completeLoad();
+ return;
+ }
+
+ this._managerProxy.connect('notify::g-name-owner', () => {
+ if (this._managerProxy.g_name_owner)
+ this._onNameAppeared();
+ else
+ this._onNameVanished();
+ });
+
+ if (this._managerProxy.g_name_owner)
+ this._onNameAppeared();
+ }
+
+ async _onNameAppeared() {
+ try {
+ const [objects] = await this._managerProxy.GetManagedObjectsAsync();
+
+ if (!objects) {
+ this._completeLoad();
+ return;
+ }
+
+ const objectPaths = Object.keys(objects);
+ await Promise.allSettled(objectPaths.flatMap(objectPath => {
+ const object = objects[objectPath];
+ const interfaceNames = Object.getOwnPropertyNames(object);
+ return interfaceNames.map(
+ ifaceName => this._addInterface(objectPath, ifaceName));
+ }));
+ } catch (error) {
+ logError(error, `could not get remote objects for service ${this._serviceName} path ${this._managerPath}`);
+ } finally {
+ this._completeLoad();
+ }
+ }
+
+ _onNameVanished() {
+ let objectPaths = Object.keys(this._objects);
+ for (let i = 0; i < objectPaths.length; i++) {
+ let objectPath = objectPaths[i];
+ let object = this._objects[objectPath];
+
+ let interfaceNames = Object.keys(object);
+ for (let j = 0; j < interfaceNames.length; j++) {
+ let interfaceName = interfaceNames[j];
+
+ if (object[interfaceName])
+ this._removeInterface(objectPath, interfaceName);
+ }
+ }
+ }
+
+ _registerInterfaces(interfaces) {
+ for (let i = 0; i < interfaces.length; i++) {
+ let info = Gio.DBusInterfaceInfo.new_for_xml(interfaces[i]);
+ this._interfaceInfos[info.name] = info;
+ }
+ }
+
+ getProxy(objectPath, interfaceName) {
+ let object = this._objects[objectPath];
+
+ if (!object)
+ return null;
+
+ return object[interfaceName];
+ }
+
+ getProxiesForInterface(interfaceName) {
+ let proxyList = this._interfaces[interfaceName];
+
+ if (!proxyList)
+ return [];
+
+ return proxyList;
+ }
+
+ getAllProxies() {
+ let proxies = [];
+
+ let objectPaths = Object.keys(this._objects);
+ for (let i = 0; i < objectPaths.length; i++) {
+ let object = this._objects[objectPaths];
+
+ let interfaceNames = Object.keys(object);
+ for (let j = 0; j < interfaceNames.length; j++) {
+ let interfaceName = interfaceNames[j];
+ if (object[interfaceName])
+ proxies.push(object(interfaceName));
+ }
+ }
+
+ return proxies;
+ }
+};
diff --git a/js/misc/params.js b/js/misc/params.js
new file mode 100644
index 0000000..817d66c
--- /dev/null
+++ b/js/misc/params.js
@@ -0,0 +1,28 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported parse */
+
+// parse:
+// @params: caller-provided parameter object, or %null
+// @defaults-provided defaults object
+// @allowExtras: whether or not to allow properties not in @default
+//
+// Examines @params and fills in default values from @defaults for
+// any properties in @defaults that don't appear in @params. If
+// @allowExtras is not %true, it will throw an error if @params
+// contains any properties that aren't in @defaults.
+//
+// If @params is %null, this returns the values from @defaults.
+//
+// Return value: a new object, containing the merged parameters from
+// @params and @defaults
+function parse(params = {}, defaults, allowExtras) {
+ if (!allowExtras) {
+ for (let prop in params) {
+ if (!(prop in defaults))
+ throw new Error(`Unrecognized parameter "${prop}"`);
+ }
+ }
+
+ let defaultsCopy = Object.assign({}, defaults);
+ return Object.assign(defaultsCopy, params);
+}
diff --git a/js/misc/parentalControlsManager.js b/js/misc/parentalControlsManager.js
new file mode 100644
index 0000000..48f0ca2
--- /dev/null
+++ b/js/misc/parentalControlsManager.js
@@ -0,0 +1,153 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+//
+// Copyright (C) 2018, 2019, 2020 Endless Mobile, Inc.
+//
+// This is a GNOME Shell component to wrap the interactions over
+// D-Bus with the malcontent library.
+//
+// Licensed under the GNU General Public License Version 2
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+/* exported getDefault */
+
+const { Gio, GObject, Shell } = imports.gi;
+
+// We require libmalcontent ā‰„ 0.6.0
+const HAVE_MALCONTENT = imports.package.checkSymbol(
+ 'Malcontent', '0', 'ManagerGetValueFlags');
+
+var Malcontent = null;
+if (HAVE_MALCONTENT) {
+ Malcontent = imports.gi.Malcontent;
+ Gio._promisify(Malcontent.Manager.prototype, 'get_app_filter_async');
+}
+
+let _singleton = null;
+
+function getDefault() {
+ if (_singleton === null)
+ _singleton = new ParentalControlsManager();
+
+ return _singleton;
+}
+
+// A manager class which provides cached access to the constructing userā€™s
+// parental controls settings. Itā€™s possible for the userā€™s parental controls
+// to change at runtime if the Parental Controls application is used by an
+// administrator from within the userā€™s session.
+var ParentalControlsManager = GObject.registerClass({
+ Signals: {
+ 'app-filter-changed': {},
+ },
+}, class ParentalControlsManager extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._initialized = false;
+ this._disabled = false;
+ this._appFilter = null;
+
+ this._initializeManager();
+ }
+
+ async _initializeManager() {
+ if (!HAVE_MALCONTENT) {
+ console.debug('Skipping parental controls support, malcontent not found');
+ this._initialized = true;
+ this.emit('app-filter-changed');
+ return;
+ }
+
+ try {
+ const connection = await Gio.DBus.get(Gio.BusType.SYSTEM, null);
+ this._manager = new Malcontent.Manager({ connection });
+ this._appFilter = await this._getAppFilter();
+ } catch (e) {
+ logError(e, 'Failed to get parental controls settings');
+ return;
+ }
+
+ this._manager.connect('app-filter-changed', this._onAppFilterChanged.bind(this));
+
+ // Signal initialisation is complete.
+ this._initialized = true;
+ this.emit('app-filter-changed');
+ }
+
+ async _getAppFilter() {
+ let appFilter = null;
+
+ try {
+ appFilter = await this._manager.get_app_filter_async(
+ Shell.util_get_uid(),
+ Malcontent.ManagerGetValueFlags.NONE,
+ null);
+ } catch (e) {
+ if (!e.matches(Malcontent.ManagerError, Malcontent.ManagerError.DISABLED))
+ throw e;
+
+ console.debug('Parental controls globally disabled');
+ this._disabled = true;
+ }
+
+ return appFilter;
+ }
+
+ async _onAppFilterChanged(manager, uid) {
+ // Emit 'changed' signal only if app-filter is changed for currently logged-in user.
+ let currentUid = Shell.util_get_uid();
+ if (currentUid !== uid)
+ return;
+
+ try {
+ this._appFilter = await this._getAppFilter();
+ this.emit('app-filter-changed');
+ } catch (e) {
+ // Log an error and keep the old app filter.
+ logError(e, `Failed to get new MctAppFilter for uid ${Shell.util_get_uid()} on app-filter-changed`);
+ }
+ }
+
+ get initialized() {
+ return this._initialized;
+ }
+
+ // Calculate whether the given app (a Gio.DesktopAppInfo) should be shown
+ // on the desktop, in search results, etc. The app should be shown if:
+ // - The .desktop file doesnā€™t say it should be hidden.
+ // - The executable from the .desktop fileā€™s Exec line isnā€™t denied in
+ // the userā€™s parental controls.
+ // - None of the flatpak app IDs from the X-Flatpak and the
+ // X-Flatpak-RenamedFrom lines are denied in the userā€™s parental
+ // controls.
+ shouldShowApp(appInfo) {
+ // Quick decision?
+ if (!appInfo.should_show())
+ return false;
+
+ // Are parental controls enabled (at configure time or runtime)?
+ if (!HAVE_MALCONTENT || this._disabled)
+ return true;
+
+ // Have we finished initialising yet?
+ if (!this.initialized) {
+ console.debug(`Hiding app because parental controls not yet initialised: ${appInfo.get_id()}`);
+ return false;
+ }
+
+ return this._appFilter.is_appinfo_allowed(appInfo);
+ }
+});
diff --git a/js/misc/permissionStore.js b/js/misc/permissionStore.js
new file mode 100644
index 0000000..46c5d54
--- /dev/null
+++ b/js/misc/permissionStore.js
@@ -0,0 +1,16 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported PermissionStore */
+
+const Gio = imports.gi.Gio;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const PermissionStoreIface = loadInterfaceXML('org.freedesktop.impl.portal.PermissionStore');
+const PermissionStoreProxy = Gio.DBusProxy.makeProxyWrapper(PermissionStoreIface);
+
+function PermissionStore(initCallback, cancellable) {
+ return new PermissionStoreProxy(Gio.DBus.session,
+ 'org.freedesktop.impl.portal.PermissionStore',
+ '/org/freedesktop/impl/portal/PermissionStore',
+ initCallback, cancellable);
+}
diff --git a/js/misc/signalTracker.js b/js/misc/signalTracker.js
new file mode 100644
index 0000000..3b71fbc
--- /dev/null
+++ b/js/misc/signalTracker.js
@@ -0,0 +1,269 @@
+/* exported TransientSignalHolder, connectObject, disconnectObject */
+const { GObject } = imports.gi;
+
+const destroyableTypes = [];
+
+/**
+ * @private
+ * @param {Object} obj - an object
+ * @returns {bool} - true if obj has a 'destroy' GObject signal
+ */
+function _hasDestroySignal(obj) {
+ return destroyableTypes.some(type => obj instanceof type);
+}
+
+var TransientSignalHolder = GObject.registerClass(
+class TransientSignalHolder extends GObject.Object {
+ static [GObject.signals] = {
+ 'destroy': {},
+ };
+
+ constructor(owner) {
+ super();
+
+ if (_hasDestroySignal(owner))
+ owner.connectObject('destroy', () => this.destroy(), this);
+ }
+
+ destroy() {
+ this.emit('destroy');
+ }
+});
+registerDestroyableType(TransientSignalHolder);
+
+class SignalManager {
+ /**
+ * @returns {SignalManager} - the SignalManager singleton
+ */
+ static getDefault() {
+ if (!this._singleton)
+ this._singleton = new SignalManager();
+ return this._singleton;
+ }
+
+ constructor() {
+ this._signalTrackers = new Map();
+ }
+
+ /**
+ * @param {Object} obj - object to get signal tracker for
+ * @returns {SignalTracker} - the signal tracker for object
+ */
+ getSignalTracker(obj) {
+ let signalTracker = this._signalTrackers.get(obj);
+ if (signalTracker === undefined) {
+ signalTracker = new SignalTracker(obj);
+ this._signalTrackers.set(obj, signalTracker);
+ }
+ return signalTracker;
+ }
+
+ /**
+ * @param {Object} obj - object to get signal tracker for
+ * @returns {?SignalTracker} - the signal tracker for object if it exists
+ */
+ maybeGetSignalTracker(obj) {
+ return this._signalTrackers.get(obj) ?? null;
+ }
+
+ /*
+ * @param {Object} obj - object to remove signal tracker for
+ * @returns {void}
+ */
+ removeSignalTracker(obj) {
+ this._signalTrackers.delete(obj);
+ }
+}
+
+class SignalTracker {
+ /**
+ * @param {Object=} owner - object that owns the tracker
+ */
+ constructor(owner) {
+ if (_hasDestroySignal(owner))
+ this._ownerDestroyId = owner.connect_after('destroy', () => this.clear());
+
+ this._owner = owner;
+ this._map = new Map();
+ }
+
+ /**
+ * @typedef SignalData
+ * @property {number[]} ownerSignals - a list of handler IDs
+ * @property {number} destroyId - destroy handler ID of tracked object
+ */
+
+ /**
+ * @private
+ * @param {Object} obj - a tracked object
+ * @returns {SignalData} - signal data for object
+ */
+ _getSignalData(obj) {
+ let data = this._map.get(obj);
+ if (data === undefined) {
+ data = { ownerSignals: [], destroyId: 0 };
+ this._map.set(obj, data);
+ }
+ return data;
+ }
+
+ /**
+ * @private
+ * @param {GObject.Object} obj - tracked widget
+ */
+ _trackDestroy(obj) {
+ const signalData = this._getSignalData(obj);
+ if (signalData.destroyId)
+ return;
+ signalData.destroyId = obj.connect_after('destroy', () => this.untrack(obj));
+ }
+
+ _disconnectSignalForProto(proto, obj, id) {
+ proto['disconnect'].call(obj, id);
+ }
+
+ _getObjectProto(obj) {
+ return obj instanceof GObject.Object
+ ? GObject.Object.prototype
+ : Object.getPrototypeOf(obj);
+ }
+
+ _disconnectSignal(obj, id) {
+ this._disconnectSignalForProto(this._getObjectProto(obj), obj, id);
+ }
+
+ _removeTracker() {
+ if (this._ownerDestroyId)
+ this._disconnectSignal(this._owner, this._ownerDestroyId);
+
+ SignalManager.getDefault().removeSignalTracker(this._owner);
+
+ delete this._ownerDestroyId;
+ delete this._owner;
+ }
+
+ /**
+ * @param {Object} obj - tracked object
+ * @param {...number} handlerIds - tracked handler IDs
+ * @returns {void}
+ */
+ track(obj, ...handlerIds) {
+ if (_hasDestroySignal(obj))
+ this._trackDestroy(obj);
+
+ this._getSignalData(obj).ownerSignals.push(...handlerIds);
+ }
+
+ /**
+ * @param {Object} obj - tracked object instance
+ * @returns {void}
+ */
+ untrack(obj) {
+ const { ownerSignals, destroyId } = this._getSignalData(obj);
+ this._map.delete(obj);
+
+ const ownerProto = this._getObjectProto(this._owner);
+ ownerSignals.forEach(id =>
+ this._disconnectSignalForProto(ownerProto, this._owner, id));
+ if (destroyId)
+ this._disconnectSignal(obj, destroyId);
+
+ if (this._map.size === 0)
+ this._removeTracker();
+ }
+
+ /**
+ * @returns {void}
+ */
+ clear() {
+ this._map.forEach((_, obj) => this.untrack(obj));
+ }
+
+ /**
+ * @returns {void}
+ */
+ destroy() {
+ this.clear();
+ this._removeTracker();
+ }
+}
+
+/**
+ * Connect one or more signals, and associate the handlers
+ * with a tracked object.
+ *
+ * All handlers for a particular object can be disconnected
+ * by calling disconnectObject(). If object is a {Clutter.widget},
+ * this is done automatically when the widget is destroyed.
+ *
+ * @param {object} thisObj - the emitter object
+ * @param {...any} args - a sequence of signal-name/handler pairs
+ * with an optional flags value, followed by an object to track
+ * @returns {void}
+ */
+function connectObject(thisObj, ...args) {
+ const getParams = argArray => {
+ const [signalName, handler, arg, ...rest] = argArray;
+ if (typeof arg !== 'number')
+ return [signalName, handler, 0, arg, ...rest];
+
+ const flags = arg;
+ let flagsMask = 0;
+ Object.values(GObject.ConnectFlags).forEach(v => (flagsMask |= v));
+ if (!(flags & flagsMask))
+ throw new Error(`Invalid flag value ${flags}`);
+ if (flags & GObject.ConnectFlags.SWAPPED)
+ throw new Error('Swapped signals are not supported');
+ return [signalName, handler, flags, ...rest];
+ };
+
+ const connectSignal = (emitter, signalName, handler, flags) => {
+ const isGObject = emitter instanceof GObject.Object;
+ const func = (flags & GObject.ConnectFlags.AFTER) && isGObject
+ ? 'connect_after'
+ : 'connect';
+ const emitterProto = isGObject
+ ? GObject.Object.prototype
+ : Object.getPrototypeOf(emitter);
+ return emitterProto[func].call(emitter, signalName, handler);
+ };
+
+ const signalIds = [];
+ while (args.length > 1) {
+ const [signalName, handler, flags, ...rest] = getParams(args);
+ signalIds.push(connectSignal(thisObj, signalName, handler, flags));
+ args = rest;
+ }
+
+ const obj = args.at(0) ?? globalThis;
+ const tracker = SignalManager.getDefault().getSignalTracker(thisObj);
+ tracker.track(obj, ...signalIds);
+}
+
+/**
+ * Disconnect all signals that were connected for
+ * the specified tracked object
+ *
+ * @param {Object} thisObj - the emitter object
+ * @param {Object} obj - the tracked object
+ * @returns {void}
+ */
+function disconnectObject(thisObj, obj) {
+ SignalManager.getDefault().maybeGetSignalTracker(thisObj)?.untrack(obj);
+}
+
+/**
+ * Register a GObject type as having a 'destroy' signal
+ * that should disconnect all handlers
+ *
+ * @param {GObject.Type} gtype - a GObject type
+ */
+function registerDestroyableType(gtype) {
+ if (!GObject.type_is_a(gtype, GObject.Object))
+ throw new Error(`${gtype} is not a GObject subclass`);
+
+ if (!GObject.signal_lookup('destroy', gtype))
+ throw new Error(`${gtype} does not have a destroy signal`);
+
+ destroyableTypes.push(gtype);
+}
diff --git a/js/misc/signals.js b/js/misc/signals.js
new file mode 100644
index 0000000..f4acced
--- /dev/null
+++ b/js/misc/signals.js
@@ -0,0 +1,22 @@
+const Signals = imports.signals;
+const SignalTracker = imports.misc.signalTracker;
+
+var EventEmitter = class EventEmitter {
+ connectObject(...args) {
+ return SignalTracker.connectObject(this, ...args);
+ }
+
+ disconnectObject(...args) {
+ return SignalTracker.disconnectObject(this, ...args);
+ }
+
+ connect_object(...args) {
+ return this.connectObject(...args);
+ }
+
+ disconnect_object(...args) {
+ return this.disconnectObject(...args);
+ }
+};
+
+Signals.addSignalMethods(EventEmitter.prototype);
diff --git a/js/misc/smartcardManager.js b/js/misc/smartcardManager.js
new file mode 100644
index 0000000..661b337
--- /dev/null
+++ b/js/misc/smartcardManager.js
@@ -0,0 +1,119 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported getSmartcardManager */
+
+const Gio = imports.gi.Gio;
+const Signals = imports.misc.signals;
+
+const ObjectManager = imports.misc.objectManager;
+
+const SmartcardTokenIface = `
+<node>
+<interface name="org.gnome.SettingsDaemon.Smartcard.Token">
+ <property name="Name" type="s" access="read"/>
+ <property name="Driver" type="o" access="read"/>
+ <property name="IsInserted" type="b" access="read"/>
+ <property name="UsedToLogin" type="b" access="read"/>
+</interface>
+</node>`;
+
+let _smartcardManager = null;
+
+function getSmartcardManager() {
+ if (_smartcardManager == null)
+ _smartcardManager = new SmartcardManager();
+
+ return _smartcardManager;
+}
+
+var SmartcardManager = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ this._objectManager = new ObjectManager.ObjectManager({
+ connection: Gio.DBus.session,
+ name: 'org.gnome.SettingsDaemon.Smartcard',
+ objectPath: '/org/gnome/SettingsDaemon/Smartcard',
+ knownInterfaces: [SmartcardTokenIface],
+ onLoaded: this._onLoaded.bind(this),
+ });
+ this._insertedTokens = {};
+ this._loginToken = null;
+ }
+
+ _onLoaded() {
+ let tokens = this._objectManager.getProxiesForInterface('org.gnome.SettingsDaemon.Smartcard.Token');
+
+ for (let i = 0; i < tokens.length; i++)
+ this._addToken(tokens[i]);
+
+ this._objectManager.connect('interface-added', (objectManager, interfaceName, proxy) => {
+ if (interfaceName == 'org.gnome.SettingsDaemon.Smartcard.Token')
+ this._addToken(proxy);
+ });
+
+ this._objectManager.connect('interface-removed', (objectManager, interfaceName, proxy) => {
+ if (interfaceName == 'org.gnome.SettingsDaemon.Smartcard.Token')
+ this._removeToken(proxy);
+ });
+ }
+
+ _updateToken(token) {
+ let objectPath = token.get_object_path();
+
+ delete this._insertedTokens[objectPath];
+
+ if (token.IsInserted)
+ this._insertedTokens[objectPath] = token;
+
+ if (token.UsedToLogin)
+ this._loginToken = token;
+ }
+
+ _addToken(token) {
+ this._updateToken(token);
+
+ token.connect('g-properties-changed', (proxy, properties) => {
+ const isInsertedChanged = !!properties.lookup_value('IsInserted', null);
+ if (isInsertedChanged) {
+ this._updateToken(token);
+
+ if (token.IsInserted)
+ this.emit('smartcard-inserted', token);
+ else
+ this.emit('smartcard-removed', token);
+ }
+ });
+
+ // Emit a smartcard-inserted at startup if it's already plugged in
+ if (token.IsInserted)
+ this.emit('smartcard-inserted', token);
+ }
+
+ _removeToken(token) {
+ let objectPath = token.get_object_path();
+
+ if (this._insertedTokens[objectPath] == token) {
+ delete this._insertedTokens[objectPath];
+ this.emit('smartcard-removed', token);
+ }
+
+ if (this._loginToken == token)
+ this._loginToken = null;
+
+ token.disconnectAll();
+ }
+
+ hasInsertedTokens() {
+ return Object.keys(this._insertedTokens).length > 0;
+ }
+
+ hasInsertedLoginToken() {
+ if (!this._loginToken)
+ return false;
+
+ if (!this._loginToken.IsInserted)
+ return false;
+
+ return true;
+ }
+};
diff --git a/js/misc/systemActions.js b/js/misc/systemActions.js
new file mode 100644
index 0000000..c57afe5
--- /dev/null
+++ b/js/misc/systemActions.js
@@ -0,0 +1,474 @@
+/* exported getDefault */
+const { AccountsService, Clutter, Gdm, Gio, GLib, GObject, Meta } = imports.gi;
+
+const GnomeSession = imports.misc.gnomeSession;
+const LoginManager = imports.misc.loginManager;
+const Main = imports.ui.main;
+const Screenshot = imports.ui.screenshot;
+
+const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown';
+const LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen';
+const DISABLE_USER_SWITCH_KEY = 'disable-user-switching';
+const DISABLE_LOCK_SCREEN_KEY = 'disable-lock-screen';
+const DISABLE_LOG_OUT_KEY = 'disable-log-out';
+const DISABLE_RESTART_KEY = 'disable-restart-buttons';
+const ALWAYS_SHOW_LOG_OUT_KEY = 'always-show-log-out';
+
+const POWER_OFF_ACTION_ID = 'power-off';
+const RESTART_ACTION_ID = 'restart';
+const LOCK_SCREEN_ACTION_ID = 'lock-screen';
+const LOGOUT_ACTION_ID = 'logout';
+const SUSPEND_ACTION_ID = 'suspend';
+const SWITCH_USER_ACTION_ID = 'switch-user';
+const LOCK_ORIENTATION_ACTION_ID = 'lock-orientation';
+const SCREENSHOT_UI_ACTION_ID = 'open-screenshot-ui';
+
+let _singleton = null;
+
+function getDefault() {
+ if (_singleton == null)
+ _singleton = new SystemActions();
+
+ return _singleton;
+}
+
+const SystemActions = GObject.registerClass({
+ Properties: {
+ 'can-power-off': GObject.ParamSpec.boolean(
+ 'can-power-off', 'can-power-off', 'can-power-off',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'can-restart': GObject.ParamSpec.boolean(
+ 'can-restart', 'can-restart', 'can-restart',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'can-suspend': GObject.ParamSpec.boolean(
+ 'can-suspend', 'can-suspend', 'can-suspend',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'can-lock-screen': GObject.ParamSpec.boolean(
+ 'can-lock-screen', 'can-lock-screen', 'can-lock-screen',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'can-switch-user': GObject.ParamSpec.boolean(
+ 'can-switch-user', 'can-switch-user', 'can-switch-user',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'can-logout': GObject.ParamSpec.boolean(
+ 'can-logout', 'can-logout', 'can-logout',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'can-lock-orientation': GObject.ParamSpec.boolean(
+ 'can-lock-orientation', 'can-lock-orientation', 'can-lock-orientation',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'orientation-lock-icon': GObject.ParamSpec.string(
+ 'orientation-lock-icon', 'orientation-lock-icon', 'orientation-lock-icon',
+ GObject.ParamFlags.READWRITE,
+ null),
+ },
+}, class SystemActions extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._canHavePowerOff = true;
+ this._canHaveSuspend = true;
+
+ function tokenizeKeywords(keywords) {
+ return keywords.split(';').map(keyword => GLib.str_tokenize_and_fold(keyword, null)).flat(2);
+ }
+
+ this._actions = new Map();
+ this._actions.set(POWER_OFF_ACTION_ID, {
+ // Translators: The name of the power-off action in search
+ name: C_("search-result", "Power Off"),
+ iconName: 'system-shutdown-symbolic',
+ // Translators: A list of keywords that match the power-off action, separated by semicolons
+ keywords: tokenizeKeywords(_('power off;shutdown;halt;stop')),
+ available: false,
+ });
+ this._actions.set(RESTART_ACTION_ID, {
+ // Translators: The name of the restart action in search
+ name: C_('search-result', 'Restart'),
+ iconName: 'system-reboot-symbolic',
+ // Translators: A list of keywords that match the restart action, separated by semicolons
+ keywords: tokenizeKeywords(_('reboot;restart;')),
+ available: false,
+ });
+ this._actions.set(LOCK_SCREEN_ACTION_ID, {
+ // Translators: The name of the lock screen action in search
+ name: C_("search-result", "Lock Screen"),
+ iconName: 'system-lock-screen-symbolic',
+ // Translators: A list of keywords that match the lock screen action, separated by semicolons
+ keywords: tokenizeKeywords(_('lock screen')),
+ available: false,
+ });
+ this._actions.set(LOGOUT_ACTION_ID, {
+ // Translators: The name of the logout action in search
+ name: C_("search-result", "Log Out"),
+ iconName: 'system-log-out-symbolic',
+ // Translators: A list of keywords that match the logout action, separated by semicolons
+ keywords: tokenizeKeywords(_('logout;log out;sign off')),
+ available: false,
+ });
+ this._actions.set(SUSPEND_ACTION_ID, {
+ // Translators: The name of the suspend action in search
+ name: C_("search-result", "Suspend"),
+ iconName: 'media-playback-pause-symbolic',
+ // Translators: A list of keywords that match the suspend action, separated by semicolons
+ keywords: tokenizeKeywords(_('suspend;sleep')),
+ available: false,
+ });
+ this._actions.set(SWITCH_USER_ACTION_ID, {
+ // Translators: The name of the switch user action in search
+ name: C_("search-result", "Switch User"),
+ iconName: 'system-switch-user-symbolic',
+ // Translators: A list of keywords that match the switch user action, separated by semicolons
+ keywords: tokenizeKeywords(_('switch user')),
+ available: false,
+ });
+ this._actions.set(LOCK_ORIENTATION_ACTION_ID, {
+ name: '',
+ iconName: '',
+ // Translators: A list of keywords that match the lock orientation action, separated by semicolons
+ keywords: tokenizeKeywords(_('lock orientation;unlock orientation;screen;rotation')),
+ available: false,
+ });
+ this._actions.set(SCREENSHOT_UI_ACTION_ID, {
+ // Translators: The name of the screenshot UI action in search
+ name: C_('search-result', 'Take a Screenshot'),
+ iconName: 'record-screen-symbolic',
+ // Translators: A list of keywords that match the screenshot UI action, separated by semicolons
+ keywords: tokenizeKeywords(_('screenshot;screencast;snip;capture;record')),
+ available: true,
+ });
+
+ this._loginScreenSettings = new Gio.Settings({ schema_id: LOGIN_SCREEN_SCHEMA });
+ this._lockdownSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA });
+ this._orientationSettings = new Gio.Settings({ schema_id: 'org.gnome.settings-daemon.peripherals.touchscreen' });
+
+ this._session = new GnomeSession.SessionManager();
+ this._loginManager = LoginManager.getLoginManager();
+ this._monitorManager = Meta.MonitorManager.get();
+
+ this._userManager = AccountsService.UserManager.get_default();
+
+ this._userManager.connect('notify::is-loaded',
+ () => this._updateMultiUser());
+ this._userManager.connect('notify::has-multiple-users',
+ () => this._updateMultiUser());
+ this._userManager.connect('user-added',
+ () => this._updateMultiUser());
+ this._userManager.connect('user-removed',
+ () => this._updateMultiUser());
+
+ this._lockdownSettings.connect(`changed::${DISABLE_USER_SWITCH_KEY}`,
+ () => this._updateSwitchUser());
+ this._lockdownSettings.connect(`changed::${DISABLE_LOG_OUT_KEY}`,
+ () => this._updateLogout());
+ global.settings.connect(`changed::${ALWAYS_SHOW_LOG_OUT_KEY}`,
+ () => this._updateLogout());
+
+ this._lockdownSettings.connect(`changed::${DISABLE_LOCK_SCREEN_KEY}`,
+ () => this._updateLockScreen());
+
+ this._lockdownSettings.connect(`changed::${DISABLE_LOG_OUT_KEY}`,
+ () => this._updateHaveShutdown());
+
+ this.forceUpdate();
+
+ this._orientationSettings.connect('changed::orientation-lock', () => {
+ this._updateOrientationLock();
+ this._updateOrientationLockStatus();
+ });
+ Main.layoutManager.connect('monitors-changed',
+ () => this._updateOrientationLock());
+ this._monitorManager.connect('notify::panel-orientation-managed',
+ () => this._updateOrientationLock());
+ this._updateOrientationLock();
+ this._updateOrientationLockStatus();
+
+ Main.sessionMode.connect('updated', () => this._sessionUpdated());
+ this._sessionUpdated();
+ }
+
+ get canPowerOff() {
+ return this._actions.get(POWER_OFF_ACTION_ID).available;
+ }
+
+ get canRestart() {
+ return this._actions.get(RESTART_ACTION_ID).available;
+ }
+
+ get canSuspend() {
+ return this._actions.get(SUSPEND_ACTION_ID).available;
+ }
+
+ get canLockScreen() {
+ return this._actions.get(LOCK_SCREEN_ACTION_ID).available;
+ }
+
+ get canSwitchUser() {
+ return this._actions.get(SWITCH_USER_ACTION_ID).available;
+ }
+
+ get canLogout() {
+ return this._actions.get(LOGOUT_ACTION_ID).available;
+ }
+
+ get canLockOrientation() {
+ return this._actions.get(LOCK_ORIENTATION_ACTION_ID).available;
+ }
+
+ get orientationLockIcon() {
+ return this._actions.get(LOCK_ORIENTATION_ACTION_ID).iconName;
+ }
+
+ _updateOrientationLock() {
+ const available = this._monitorManager.get_panel_orientation_managed();
+
+ this._actions.get(LOCK_ORIENTATION_ACTION_ID).available = available;
+
+ this.notify('can-lock-orientation');
+ }
+
+ _updateOrientationLockStatus() {
+ let locked = this._orientationSettings.get_boolean('orientation-lock');
+ let action = this._actions.get(LOCK_ORIENTATION_ACTION_ID);
+
+ // Translators: The name of the lock orientation action in search
+ // and in the system status menu
+ let name = locked
+ ? C_('search-result', 'Unlock Screen Rotation')
+ : C_('search-result', 'Lock Screen Rotation');
+ let iconName = locked
+ ? 'rotation-locked-symbolic'
+ : 'rotation-allowed-symbolic';
+
+ action.name = name;
+ action.iconName = iconName;
+
+ this.notify('orientation-lock-icon');
+ }
+
+ _sessionUpdated() {
+ this._updateLockScreen();
+ this._updatePowerOff();
+ this._updateSuspend();
+ this._updateMultiUser();
+ }
+
+ forceUpdate() {
+ // Whether those actions are available or not depends on both lockdown
+ // settings and Polkit policy - we don't get change notifications for the
+ // latter, so their value may be outdated; force an update now
+ this._updateHaveShutdown();
+ this._updateHaveSuspend();
+ }
+
+ getMatchingActions(terms) {
+ // terms is a list of strings
+ terms = terms.map(
+ term => GLib.str_tokenize_and_fold(term, null)[0]).flat(2);
+
+ // tokenizing may return an empty array
+ if (terms.length === 0)
+ return [];
+
+ let results = [];
+
+ for (let [key, { available, keywords }] of this._actions) {
+ if (available && terms.every(t => keywords.some(k => k.startsWith(t))))
+ results.push(key);
+ }
+
+ return results;
+ }
+
+ getName(id) {
+ return this._actions.get(id).name;
+ }
+
+ getIconName(id) {
+ return this._actions.get(id).iconName;
+ }
+
+ activateAction(id) {
+ switch (id) {
+ case POWER_OFF_ACTION_ID:
+ this.activatePowerOff();
+ break;
+ case RESTART_ACTION_ID:
+ this.activateRestart();
+ break;
+ case LOCK_SCREEN_ACTION_ID:
+ this.activateLockScreen();
+ break;
+ case LOGOUT_ACTION_ID:
+ this.activateLogout();
+ break;
+ case SUSPEND_ACTION_ID:
+ this.activateSuspend();
+ break;
+ case SWITCH_USER_ACTION_ID:
+ this.activateSwitchUser();
+ break;
+ case LOCK_ORIENTATION_ACTION_ID:
+ this.activateLockOrientation();
+ break;
+ case SCREENSHOT_UI_ACTION_ID:
+ this.activateScreenshotUI();
+ break;
+ }
+ }
+
+ _updateLockScreen() {
+ let showLock = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ let allowLockScreen = !this._lockdownSettings.get_boolean(DISABLE_LOCK_SCREEN_KEY);
+ this._actions.get(LOCK_SCREEN_ACTION_ID).available = showLock && allowLockScreen && LoginManager.canLock();
+ this.notify('can-lock-screen');
+ }
+
+ async _updateHaveShutdown() {
+ try {
+ const [canShutdown] = await this._session.CanShutdownAsync();
+ this._canHavePowerOff = canShutdown;
+ } catch (e) {
+ this._canHavePowerOff = false;
+ }
+ this._updatePowerOff();
+ }
+
+ _updatePowerOff() {
+ let disabled = Main.sessionMode.isLocked ||
+ (Main.sessionMode.isGreeter &&
+ this._loginScreenSettings.get_boolean(DISABLE_RESTART_KEY));
+ this._actions.get(POWER_OFF_ACTION_ID).available = this._canHavePowerOff && !disabled;
+ this.notify('can-power-off');
+
+ this._actions.get(RESTART_ACTION_ID).available = this._canHavePowerOff && !disabled;
+ this.notify('can-restart');
+ }
+
+ async _updateHaveSuspend() {
+ const {canSuspend, needsAuth} = await this._loginManager.canSuspend();
+ this._canHaveSuspend = canSuspend;
+ this._suspendNeedsAuth = needsAuth;
+ this._updateSuspend();
+ }
+
+ _updateSuspend() {
+ let disabled = (Main.sessionMode.isLocked &&
+ this._suspendNeedsAuth) ||
+ (Main.sessionMode.isGreeter &&
+ this._loginScreenSettings.get_boolean(DISABLE_RESTART_KEY));
+ this._actions.get(SUSPEND_ACTION_ID).available = this._canHaveSuspend && !disabled;
+ this.notify('can-suspend');
+ }
+
+ _updateMultiUser() {
+ this._updateLogout();
+ this._updateSwitchUser();
+ }
+
+ _updateSwitchUser() {
+ let allowSwitch = !this._lockdownSettings.get_boolean(DISABLE_USER_SWITCH_KEY);
+ let multiUser = this._userManager.can_switch() && this._userManager.has_multiple_users;
+ let shouldShowInMode = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+
+ let visible = allowSwitch && multiUser && shouldShowInMode;
+ this._actions.get(SWITCH_USER_ACTION_ID).available = visible;
+ this.notify('can-switch-user');
+
+ return visible;
+ }
+
+ _updateLogout() {
+ let user = this._userManager.get_user(GLib.get_user_name());
+
+ let allowLogout = !this._lockdownSettings.get_boolean(DISABLE_LOG_OUT_KEY);
+ let alwaysShow = global.settings.get_boolean(ALWAYS_SHOW_LOG_OUT_KEY);
+ let systemAccount = user.system_account;
+ let localAccount = user.local_account;
+ let multiUser = this._userManager.has_multiple_users;
+ let multiSession = Gdm.get_session_ids().length > 1;
+ let shouldShowInMode = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+
+ let visible = allowLogout && (alwaysShow || multiUser || multiSession || systemAccount || !localAccount) && shouldShowInMode;
+ this._actions.get(LOGOUT_ACTION_ID).available = visible;
+ this.notify('can-logout');
+
+ return visible;
+ }
+
+ activateLockOrientation() {
+ if (!this._actions.get(LOCK_ORIENTATION_ACTION_ID).available)
+ throw new Error('The lock-orientation action is not available!');
+
+ let locked = this._orientationSettings.get_boolean('orientation-lock');
+ this._orientationSettings.set_boolean('orientation-lock', !locked);
+ }
+
+ activateLockScreen() {
+ if (!this._actions.get(LOCK_SCREEN_ACTION_ID).available)
+ throw new Error('The lock-screen action is not available!');
+
+ Main.screenShield.lock(true);
+ }
+
+ activateSwitchUser() {
+ if (!this._actions.get(SWITCH_USER_ACTION_ID).available)
+ throw new Error('The switch-user action is not available!');
+
+ if (Main.screenShield)
+ Main.screenShield.lock(false);
+
+ Clutter.threads_add_repaint_func_full(Clutter.RepaintFlags.POST_PAINT, () => {
+ Gdm.goto_login_session_sync(null);
+ return false;
+ });
+ }
+
+ activateLogout() {
+ if (!this._actions.get(LOGOUT_ACTION_ID).available)
+ throw new Error('The logout action is not available!');
+
+ Main.overview.hide();
+ this._session.LogoutAsync(0).catch(logError);
+ }
+
+ activatePowerOff() {
+ if (!this._actions.get(POWER_OFF_ACTION_ID).available)
+ throw new Error('The power-off action is not available!');
+
+ this._session.ShutdownAsync(0).catch(logError);
+ }
+
+ activateRestart() {
+ if (!this._actions.get(RESTART_ACTION_ID).available)
+ throw new Error('The restart action is not available!');
+
+ this._session.RebootAsync().catch(logError);
+ }
+
+ activateSuspend() {
+ if (!this._actions.get(SUSPEND_ACTION_ID).available)
+ throw new Error('The suspend action is not available!');
+
+ this._loginManager.suspend();
+ }
+
+ activateScreenshotUI() {
+ if (!this._actions.get(SCREENSHOT_UI_ACTION_ID).available)
+ throw new Error('The screenshot UI action is not available!');
+
+ if (this._overviewHiddenId)
+ return;
+
+ this._overviewHiddenId = Main.overview.connect('hidden', () => {
+ Main.overview.disconnect(this._overviewHiddenId);
+ delete this._overviewHiddenId;
+ Screenshot.showScreenshotUI();
+ });
+ }
+});
diff --git a/js/misc/util.js b/js/misc/util.js
new file mode 100644
index 0000000..e6065c4
--- /dev/null
+++ b/js/misc/util.js
@@ -0,0 +1,617 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported findUrls, spawn, spawnCommandLine, spawnApp, trySpawnCommandLine,
+ formatTime, formatTimeSpan, createTimeLabel, insertSorted,
+ ensureActorVisibleInScrollView, wiggle, lerp, GNOMEversionCompare,
+ DBusSenderChecker, Highlighter */
+
+const { Clutter, Gio, GLib, Shell, St, GnomeDesktop } = imports.gi;
+const Gettext = imports.gettext;
+
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+var SCROLL_TIME = 100;
+
+const WIGGLE_OFFSET = 6;
+const WIGGLE_DURATION = 65;
+const N_WIGGLES = 3;
+
+// http://daringfireball.net/2010/07/improved_regex_for_matching_urls
+const _balancedParens = '\\([^\\s()<>]+\\)';
+const _leadingJunk = '[\\s`(\\[{\'\\"<\u00AB\u201C\u2018]';
+const _notTrailingJunk = '[^\\s`!()\\[\\]{};:\'\\".,<>?\u00AB\u00BB\u200E\u200F\u201C\u201D\u2018\u2019\u202A\u202C]';
+
+const _urlRegexp = new RegExp(
+ `(^|${_leadingJunk})` +
+ '(' +
+ '(?:' +
+ '(?:http|https|ftp)://' + // scheme://
+ '|' +
+ 'www\\d{0,3}[.]' + // www.
+ '|' +
+ '[a-z0-9.\\-]+[.][a-z]{2,4}/' + // foo.xx/
+ ')' +
+ '(?:' + // one or more:
+ '[^\\s()<>]+' + // run of non-space non-()
+ '|' + // or
+ `${_balancedParens}` + // balanced parens
+ ')+' +
+ '(?:' + // end with:
+ `${_balancedParens}` + // balanced parens
+ '|' + // or
+ `${_notTrailingJunk}` + // last non-junk char
+ ')' +
+ ')', 'gi');
+
+let _desktopSettings = null;
+
+// findUrls:
+// @str: string to find URLs in
+//
+// Searches @str for URLs and returns an array of objects with %url
+// properties showing the matched URL string, and %pos properties indicating
+// the position within @str where the URL was found.
+//
+// Return value: the list of match objects, as described above
+function findUrls(str) {
+ let res = [], match;
+ while ((match = _urlRegexp.exec(str)))
+ res.push({ url: match[2], pos: match.index + match[1].length });
+ return res;
+}
+
+// spawn:
+// @argv: an argv array
+//
+// Runs @argv in the background, handling any errors that occur
+// when trying to start the program.
+function spawn(argv) {
+ try {
+ trySpawn(argv);
+ } catch (err) {
+ _handleSpawnError(argv[0], err);
+ }
+}
+
+// spawnCommandLine:
+// @commandLine: a command line
+//
+// Runs @commandLine in the background, handling any errors that
+// occur when trying to parse or start the program.
+function spawnCommandLine(commandLine) {
+ try {
+ let [success_, argv] = GLib.shell_parse_argv(commandLine);
+ trySpawn(argv);
+ } catch (err) {
+ _handleSpawnError(commandLine, err);
+ }
+}
+
+// spawnApp:
+// @argv: an argv array
+//
+// Runs @argv as if it was an application, handling startup notification
+function spawnApp(argv) {
+ try {
+ let app = Gio.AppInfo.create_from_commandline(argv.join(' '), null,
+ Gio.AppInfoCreateFlags.SUPPORTS_STARTUP_NOTIFICATION);
+
+ let context = global.create_app_launch_context(0, -1);
+ app.launch([], context);
+ } catch (err) {
+ _handleSpawnError(argv[0], err);
+ }
+}
+
+// trySpawn:
+// @argv: an argv array
+//
+// Runs @argv in the background. If launching @argv fails,
+// this will throw an error.
+function trySpawn(argv) {
+ var success_, pid;
+ try {
+ [success_, pid] = GLib.spawn_async(
+ null, argv, null,
+ GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD,
+ () => {
+ try {
+ global.context.restore_rlimit_nofile();
+ } catch (err) {
+ }
+ }
+ );
+ } catch (err) {
+ /* Rewrite the error in case of ENOENT */
+ if (err.matches(GLib.SpawnError, GLib.SpawnError.NOENT)) {
+ throw new GLib.SpawnError({
+ code: GLib.SpawnError.NOENT,
+ message: _('Command not found'),
+ });
+ } else if (err instanceof GLib.Error) {
+ // The exception from gjs contains an error string like:
+ // Error invoking GLib.spawn_command_line_async: Failed to
+ // execute child process "foo" (No such file or directory)
+ // We are only interested in the part in the parentheses. (And
+ // we can't pattern match the text, since it gets localized.)
+ let message = err.message.replace(/.*\((.+)\)/, '$1');
+ throw new err.constructor({ code: err.code, message });
+ } else {
+ throw err;
+ }
+ }
+
+ // Async call, we don't need the reply though
+ GnomeDesktop.start_systemd_scope(argv[0], pid, null, null, null, () => {});
+
+ // Dummy child watch; we don't want to double-fork internally
+ // because then we lose the parent-child relationship, which
+ // can break polkit. See https://bugzilla.redhat.com//show_bug.cgi?id=819275
+ GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, () => {});
+}
+
+// trySpawnCommandLine:
+// @commandLine: a command line
+//
+// Runs @commandLine in the background. If launching @commandLine
+// fails, this will throw an error.
+function trySpawnCommandLine(commandLine) {
+ let success_, argv;
+
+ try {
+ [success_, argv] = GLib.shell_parse_argv(commandLine);
+ } catch (err) {
+ // Replace "Error invoking GLib.shell_parse_argv: " with
+ // something nicer
+ err.message = err.message.replace(/[^:]*: /, `${_('Could not parse command:')}\n`);
+ throw err;
+ }
+
+ trySpawn(argv);
+}
+
+function _handleSpawnError(command, err) {
+ let title = _("Execution of ā€œ%sā€ failed:").format(command);
+ Main.notifyError(title, err.message);
+}
+
+function formatTimeSpan(date) {
+ let now = GLib.DateTime.new_now_local();
+
+ let timespan = now.difference(date);
+
+ let minutesAgo = timespan / GLib.TIME_SPAN_MINUTE;
+ let hoursAgo = timespan / GLib.TIME_SPAN_HOUR;
+ let daysAgo = timespan / GLib.TIME_SPAN_DAY;
+ let weeksAgo = daysAgo / 7;
+ let monthsAgo = daysAgo / 30;
+ let yearsAgo = weeksAgo / 52;
+
+ if (minutesAgo < 5)
+ return _("Just now");
+ if (hoursAgo < 1) {
+ return Gettext.ngettext("%d minute ago",
+ "%d minutes ago", minutesAgo).format(minutesAgo);
+ }
+ if (daysAgo < 1) {
+ return Gettext.ngettext("%d hour ago",
+ "%d hours ago", hoursAgo).format(hoursAgo);
+ }
+ if (daysAgo < 2)
+ return _("Yesterday");
+ if (daysAgo < 15) {
+ return Gettext.ngettext("%d day ago",
+ "%d days ago", daysAgo).format(daysAgo);
+ }
+ if (weeksAgo < 8) {
+ return Gettext.ngettext("%d week ago",
+ "%d weeks ago", weeksAgo).format(weeksAgo);
+ }
+ if (yearsAgo < 1) {
+ return Gettext.ngettext("%d month ago",
+ "%d months ago", monthsAgo).format(monthsAgo);
+ }
+ return Gettext.ngettext("%d year ago",
+ "%d years ago", yearsAgo).format(yearsAgo);
+}
+
+function formatTime(time, params) {
+ let date;
+ // HACK: The built-in Date type sucks at timezones, which we need for the
+ // world clock; it's often more convenient though, so allow either
+ // Date or GLib.DateTime as parameter
+ if (time instanceof Date)
+ date = GLib.DateTime.new_from_unix_local(time.getTime() / 1000);
+ else
+ date = time;
+
+ let now = GLib.DateTime.new_now_local();
+
+ let daysAgo = now.difference(date) / (24 * 60 * 60 * 1000 * 1000);
+
+ let format;
+
+ if (_desktopSettings == null)
+ _desktopSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
+ let clockFormat = _desktopSettings.get_string('clock-format');
+
+ params = Params.parse(params, {
+ timeOnly: false,
+ ampm: true,
+ });
+
+ if (clockFormat == '24h') {
+ // Show only the time if date is on today
+ if (daysAgo < 1 || params.timeOnly)
+ /* Translators: Time in 24h format */
+ format = N_("%H\u2236%M");
+ // Show the word "Yesterday" and time if date is on yesterday
+ else if (daysAgo < 2)
+ /* Translators: this is the word "Yesterday" followed by a
+ time string in 24h format. i.e. "Yesterday, 14:30" */
+ // xgettext:no-c-format
+ format = N_("Yesterday, %H\u2236%M");
+ // Show a week day and time if date is in the last week
+ else if (daysAgo < 7)
+ /* Translators: this is the week day name followed by a time
+ string in 24h format. i.e. "Monday, 14:30" */
+ // xgettext:no-c-format
+ format = N_("%A, %H\u2236%M");
+ else if (date.get_year() == now.get_year())
+ /* Translators: this is the month name and day number
+ followed by a time string in 24h format.
+ i.e. "May 25, 14:30" */
+ // xgettext:no-c-format
+ format = N_("%B %-d, %H\u2236%M");
+ else
+ /* Translators: this is the month name, day number, year
+ number followed by a time string in 24h format.
+ i.e. "May 25 2012, 14:30" */
+ // xgettext:no-c-format
+ format = N_("%B %-d %Y, %H\u2236%M");
+ } else {
+ // Show only the time if date is on today
+ if (daysAgo < 1 || params.timeOnly) // eslint-disable-line no-lonely-if
+ /* Translators: Time in 12h format */
+ format = N_("%l\u2236%M %p");
+ // Show the word "Yesterday" and time if date is on yesterday
+ else if (daysAgo < 2)
+ /* Translators: this is the word "Yesterday" followed by a
+ time string in 12h format. i.e. "Yesterday, 2:30 pm" */
+ // xgettext:no-c-format
+ format = N_("Yesterday, %l\u2236%M %p");
+ // Show a week day and time if date is in the last week
+ else if (daysAgo < 7)
+ /* Translators: this is the week day name followed by a time
+ string in 12h format. i.e. "Monday, 2:30 pm" */
+ // xgettext:no-c-format
+ format = N_("%A, %l\u2236%M %p");
+ else if (date.get_year() == now.get_year())
+ /* Translators: this is the month name and day number
+ followed by a time string in 12h format.
+ i.e. "May 25, 2:30 pm" */
+ // xgettext:no-c-format
+ format = N_("%B %-d, %l\u2236%M %p");
+ else
+ /* Translators: this is the month name, day number, year
+ number followed by a time string in 12h format.
+ i.e. "May 25 2012, 2:30 pm"*/
+ // xgettext:no-c-format
+ format = N_("%B %-d %Y, %l\u2236%M %p");
+ }
+
+ // Time in short 12h format, without the equivalent of "AM" or "PM"; used
+ // when it is clear from the context
+ if (!params.ampm)
+ format = format.replace(/\s*%p/g, '');
+
+ let formattedTime = date.format(Shell.util_translate_time_string(format));
+ // prepend LTR-mark to colon/ratio to force a text direction on times
+ return formattedTime.replace(/([:\u2236])/g, '\u200e$1');
+}
+
+function createTimeLabel(date, params) {
+ if (_desktopSettings == null)
+ _desktopSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
+
+ let label = new St.Label({ text: formatTime(date, params) });
+ _desktopSettings.connectObject(
+ 'changed::clock-format', () => (label.text = formatTime(date, params)),
+ label);
+ return label;
+}
+
+// lowerBound:
+// @array: an array or array-like object, already sorted
+// according to @cmp
+// @val: the value to add
+// @cmp: a comparator (or undefined to compare as numbers)
+//
+// Returns the position of the first element that is not
+// lower than @val, according to @cmp.
+// That is, returns the first position at which it
+// is possible to insert @val without violating the
+// order.
+// This is quite like an ordinary binary search, except
+// that it doesn't stop at first element comparing equal.
+
+function lowerBound(array, val, cmp) {
+ let min, max, mid, v;
+ cmp ||= (a, b) => a - b;
+
+ if (array.length == 0)
+ return 0;
+
+ min = 0;
+ max = array.length;
+ while (min < (max - 1)) {
+ mid = Math.floor((min + max) / 2);
+ v = cmp(array[mid], val);
+
+ if (v < 0)
+ min = mid + 1;
+ else
+ max = mid;
+ }
+
+ return min == max || cmp(array[min], val) < 0 ? max : min;
+}
+
+// insertSorted:
+// @array: an array sorted according to @cmp
+// @val: a value to insert
+// @cmp: the sorting function
+//
+// Inserts @val into @array, preserving the
+// sorting invariants.
+// Returns the position at which it was inserted
+function insertSorted(array, val, cmp) {
+ let pos = lowerBound(array, val, cmp);
+ array.splice(pos, 0, val);
+
+ return pos;
+}
+
+function ensureActorVisibleInScrollView(scrollView, actor) {
+ let adjustment = scrollView.vscroll.adjustment;
+ let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values();
+
+ let offset = 0;
+ let vfade = scrollView.get_effect("fade");
+ if (vfade)
+ offset = vfade.fade_margins.top;
+
+ let box = actor.get_allocation_box();
+ let y1 = box.y1, y2 = box.y2;
+
+ let parent = actor.get_parent();
+ while (parent != scrollView) {
+ if (!parent)
+ throw new Error("actor not in scroll view");
+
+ box = parent.get_allocation_box();
+ y1 += box.y1;
+ y2 += box.y1;
+ parent = parent.get_parent();
+ }
+
+ if (y1 < value + offset)
+ value = Math.max(0, y1 - offset);
+ else if (y2 > value + pageSize - offset)
+ value = Math.min(upper, y2 + offset - pageSize);
+ else
+ return;
+
+ adjustment.ease(value, {
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: SCROLL_TIME,
+ });
+}
+
+function wiggle(actor, params) {
+ if (!St.Settings.get().enable_animations)
+ return;
+
+ params = Params.parse(params, {
+ offset: WIGGLE_OFFSET,
+ duration: WIGGLE_DURATION,
+ wiggleCount: N_WIGGLES,
+ });
+ actor.translation_x = 0;
+
+ // Accelerate before wiggling
+ actor.ease({
+ translation_x: -params.offset,
+ duration: params.duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ // Wiggle
+ actor.ease({
+ translation_x: params.offset,
+ duration: params.duration,
+ mode: Clutter.AnimationMode.LINEAR,
+ repeatCount: params.wiggleCount,
+ autoReverse: true,
+ onComplete: () => {
+ // Decelerate and return to the original position
+ actor.ease({
+ translation_x: 0,
+ duration: params.duration,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ });
+ },
+ });
+ },
+ });
+}
+
+function lerp(start, end, progress) {
+ return start + progress * (end - start);
+}
+
+// _GNOMEversionToNumber:
+// @version: a GNOME version element
+//
+// Like Number() but returns sortable values for special-cases
+// 'alpha' and 'beta'. Returns NaN for unhandled 'versions'.
+function _GNOMEversionToNumber(version) {
+ let ret = Number(version);
+ if (!isNaN(ret))
+ return ret;
+ if (version === 'alpha')
+ return -2;
+ if (version === 'beta')
+ return -1;
+ return ret;
+}
+
+// GNOMEversionCompare:
+// @version1: a string containing a GNOME version
+// @version2: a string containing another GNOME version
+//
+// Returns an integer less than, equal to, or greater than
+// zero, if version1 is older, equal or newer than version2
+function GNOMEversionCompare(version1, version2) {
+ const v1Array = version1.split('.');
+ const v2Array = version2.split('.');
+
+ for (let i = 0; i < Math.max(v1Array.length, v2Array.length); i++) {
+ let elemV1 = _GNOMEversionToNumber(v1Array[i] || '0');
+ let elemV2 = _GNOMEversionToNumber(v2Array[i] || '0');
+ if (elemV1 < elemV2)
+ return -1;
+ if (elemV1 > elemV2)
+ return 1;
+ }
+
+ return 0;
+}
+
+var DBusSenderChecker = class {
+ /**
+ * @param {string[]} allowList - list of allowed well-known names
+ */
+ constructor(allowList) {
+ this._allowlistMap = new Map();
+
+ this._uninitializedNames = new Set(allowList);
+ this._initializedPromise = new Promise(resolve => {
+ this._resolveInitialized = resolve;
+ });
+
+ this._watchList = allowList.map(name => {
+ return Gio.DBus.watch_name(Gio.BusType.SESSION,
+ name,
+ Gio.BusNameWatcherFlags.NONE,
+ (conn_, name_, owner) => {
+ this._allowlistMap.set(name, owner);
+ this._checkAndResolveInitialized(name);
+ },
+ () => {
+ this._allowlistMap.delete(name);
+ this._checkAndResolveInitialized(name);
+ });
+ });
+ }
+
+ /**
+ * @param {string} name - bus name for which the watcher got initialized
+ */
+ _checkAndResolveInitialized(name) {
+ if (this._uninitializedNames.delete(name) &&
+ this._uninitializedNames.size === 0)
+ this._resolveInitialized();
+ }
+
+ /**
+ * @async
+ * @param {string} sender - the bus name that invoked the checked method
+ * @returns {bool}
+ */
+ async _isSenderAllowed(sender) {
+ await this._initializedPromise;
+ return [...this._allowlistMap.values()].includes(sender);
+ }
+
+ /**
+ * Check whether the bus name that invoked @invocation maps
+ * to an entry in the allow list.
+ *
+ * @async
+ * @throws
+ * @param {Gio.DBusMethodInvocation} invocation - the invocation
+ * @returns {void}
+ */
+ async checkInvocation(invocation) {
+ if (global.context.unsafe_mode)
+ return;
+
+ if (await this._isSenderAllowed(invocation.get_sender()))
+ return;
+
+ throw new GLib.Error(Gio.DBusError,
+ Gio.DBusError.ACCESS_DENIED,
+ `${invocation.get_method_name()} is not allowed`);
+ }
+
+ /**
+ * @returns {void}
+ */
+ destroy() {
+ for (const id in this._watchList)
+ Gio.DBus.unwatch_name(id);
+ this._watchList = [];
+ }
+};
+
+/* @class Highlighter Highlight given terms in text using markup. */
+var Highlighter = class {
+ /**
+ * @param {?string[]} terms - list of terms to highlight
+ */
+ constructor(terms) {
+ if (!terms)
+ return;
+
+ const escapedTerms = terms
+ .map(term => Shell.util_regex_escape(term))
+ .filter(term => term.length > 0);
+
+ if (escapedTerms.length === 0)
+ return;
+
+ this._highlightRegex = new RegExp(
+ `(${escapedTerms.join('|')})`, 'gi');
+ }
+
+ /**
+ * Highlight all occurences of the terms defined for this
+ * highlighter in the provided text using markup.
+ *
+ * @param {string} text - text to highlight the defined terms in
+ * @returns {string}
+ */
+ highlight(text) {
+ if (!this._highlightRegex)
+ return GLib.markup_escape_text(text, -1);
+
+ let escaped = [];
+ let lastMatchEnd = 0;
+ let match;
+ while ((match = this._highlightRegex.exec(text))) {
+ if (match.index > lastMatchEnd) {
+ let unmatched = GLib.markup_escape_text(
+ text.slice(lastMatchEnd, match.index), -1);
+ escaped.push(unmatched);
+ }
+ let matched = GLib.markup_escape_text(match[0], -1);
+ escaped.push(`<b>${matched}</b>`);
+ lastMatchEnd = match.index + match[0].length;
+ }
+ let unmatched = GLib.markup_escape_text(
+ text.slice(lastMatchEnd), -1);
+ escaped.push(unmatched);
+
+ return escaped.join('');
+ }
+};
diff --git a/js/misc/weather.js b/js/misc/weather.js
new file mode 100644
index 0000000..2aa340a
--- /dev/null
+++ b/js/misc/weather.js
@@ -0,0 +1,326 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WeatherClient */
+
+const { Geoclue, Gio, GLib, GWeather, Shell } = imports.gi;
+const Signals = imports.misc.signals;
+
+const PermissionStore = imports.misc.permissionStore;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+Gio._promisify(Geoclue.Simple, 'new');
+
+const WeatherIntegrationIface = loadInterfaceXML('org.gnome.Shell.WeatherIntegration');
+
+const WEATHER_BUS_NAME = 'org.gnome.Weather';
+const WEATHER_OBJECT_PATH = '/org/gnome/Weather';
+const WEATHER_INTEGRATION_IFACE = 'org.gnome.Shell.WeatherIntegration';
+
+const WEATHER_APP_ID = 'org.gnome.Weather.desktop';
+
+// Minimum time between updates to show loading indication
+var UPDATE_THRESHOLD = 10 * GLib.TIME_SPAN_MINUTE;
+
+var WeatherClient = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ this._loading = false;
+ this._locationValid = false;
+ this._lastUpdate = GLib.DateTime.new_from_unix_local(0);
+
+ this._autoLocationRequested = false;
+ this._mostRecentLocation = null;
+
+ this._gclueService = null;
+ this._gclueStarted = false;
+ this._gclueStarting = false;
+ this._gclueLocationChangedId = 0;
+
+ this._needsAuth = true;
+ this._weatherAuthorized = false;
+ this._permStore = new PermissionStore.PermissionStore(async (proxy, error) => {
+ if (error) {
+ log(`Failed to connect to permissionStore: ${error.message}`);
+ return;
+ }
+
+ if (this._permStore.g_name_owner == null) {
+ // Failed to auto-start, likely because xdg-desktop-portal
+ // isn't installed; don't restrict access to location service
+ this._weatherAuthorized = true;
+ this._updateAutoLocation();
+ return;
+ }
+
+ let [perms, data] = [{}, null];
+ try {
+ [perms, data] = await this._permStore.LookupAsync('gnome', 'geolocation');
+ } catch (err) {
+ log(`Error looking up permission: ${err.message}`);
+ }
+
+ const params = ['gnome', 'geolocation', false, data, perms];
+ this._onPermStoreChanged(this._permStore, '', params);
+ });
+ this._permStore.connectSignal('Changed',
+ this._onPermStoreChanged.bind(this));
+
+ this._locationSettings = new Gio.Settings({ schema_id: 'org.gnome.system.location' });
+ this._locationSettings.connect('changed::enabled',
+ this._updateAutoLocation.bind(this));
+
+ this._world = GWeather.Location.get_world();
+
+ const providers =
+ GWeather.Provider.METAR |
+ GWeather.Provider.MET_NO |
+ GWeather.Provider.OWM;
+ this._weatherInfo = new GWeather.Info({
+ application_id: 'org.gnome.Shell',
+ contact_info: 'https://gitlab.gnome.org/GNOME/gnome-shell/-/raw/HEAD/gnome-shell.doap',
+ enabled_providers: providers,
+ });
+ this._weatherInfo.connect_after('updated', () => {
+ this._lastUpdate = GLib.DateTime.new_now_local();
+ this.emit('changed');
+ });
+
+ this._weatherApp = null;
+ this._weatherProxy = null;
+
+ this._createWeatherProxy();
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.shell.weather',
+ });
+ this._settings.connect('changed::automatic-location',
+ this._onAutomaticLocationChanged.bind(this));
+ this._onAutomaticLocationChanged();
+ this._settings.connect('changed::locations',
+ this._onLocationsChanged.bind(this));
+ this._onLocationsChanged();
+
+ this._appSystem = Shell.AppSystem.get_default();
+ this._appSystem.connect('installed-changed',
+ this._onInstalledChanged.bind(this));
+ this._onInstalledChanged();
+ }
+
+ get available() {
+ return this._weatherApp != null;
+ }
+
+ get loading() {
+ return this._loading;
+ }
+
+ get hasLocation() {
+ return this._locationValid;
+ }
+
+ get info() {
+ return this._weatherInfo;
+ }
+
+ activateApp() {
+ if (this._weatherApp)
+ this._weatherApp.activate();
+ }
+
+ update() {
+ if (!this._locationValid)
+ return;
+
+ let now = GLib.DateTime.new_now_local();
+ // Update without loading indication if the current info is recent enough
+ if (this._weatherInfo.is_valid() &&
+ now.difference(this._lastUpdate) < UPDATE_THRESHOLD)
+ this._weatherInfo.update();
+ else
+ this._loadInfo();
+ }
+
+ get _useAutoLocation() {
+ return this._autoLocationRequested &&
+ this._locationSettings.get_boolean('enabled') &&
+ (!this._needsAuth || this._weatherAuthorized);
+ }
+
+ async _createWeatherProxy() {
+ const nodeInfo = Gio.DBusNodeInfo.new_for_xml(WeatherIntegrationIface);
+ try {
+ this._weatherProxy = await Gio.DBusProxy.new(
+ Gio.DBus.session,
+ Gio.DBusProxyFlags.DO_NOT_AUTO_START | Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES,
+ nodeInfo.lookup_interface(WEATHER_INTEGRATION_IFACE),
+ WEATHER_BUS_NAME,
+ WEATHER_OBJECT_PATH,
+ WEATHER_INTEGRATION_IFACE,
+ null);
+ } catch (e) {
+ log(`Failed to create GNOME Weather proxy: ${e}`);
+ return;
+ }
+
+ this._weatherProxy.connect('g-properties-changed',
+ this._onWeatherPropertiesChanged.bind(this));
+ this._onWeatherPropertiesChanged();
+ }
+
+ _onWeatherPropertiesChanged() {
+ if (this._weatherProxy.g_name_owner == null)
+ return;
+
+ this._settings.set_boolean('automatic-location',
+ this._weatherProxy.AutomaticLocation);
+ this._settings.set_value('locations',
+ new GLib.Variant('av', this._weatherProxy.Locations));
+ }
+
+ _onInstalledChanged() {
+ let hadApp = this._weatherApp != null;
+ this._weatherApp = this._appSystem.lookup_app(WEATHER_APP_ID);
+ let haveApp = this._weatherApp != null;
+
+ if (hadApp !== haveApp)
+ this.emit('changed');
+
+ let neededAuth = this._needsAuth;
+ this._needsAuth = this._weatherApp === null ||
+ this._weatherApp.app_info.has_key('X-Flatpak');
+
+ if (neededAuth !== this._needsAuth)
+ this._updateAutoLocation();
+ }
+
+ _loadInfo() {
+ let id = this._weatherInfo.connect('updated', () => {
+ this._weatherInfo.disconnect(id);
+ this._loading = false;
+ });
+
+ this._loading = true;
+ this.emit('changed');
+
+ this._weatherInfo.update();
+ }
+
+ _locationsEqual(loc1, loc2) {
+ if (loc1 == loc2)
+ return true;
+
+ if (loc1 == null || loc2 == null)
+ return false;
+
+ return loc1.equal(loc2);
+ }
+
+ _setLocation(location) {
+ if (this._locationsEqual(this._weatherInfo.location, location))
+ return;
+
+ this._weatherInfo.abort();
+ this._weatherInfo.set_location(location);
+ this._locationValid = location != null;
+
+ if (location)
+ this._loadInfo();
+ else
+ this.emit('changed');
+ }
+
+ _updateLocationMonitoring() {
+ if (this._useAutoLocation) {
+ if (this._gclueLocationChangedId != 0 || this._gclueService == null)
+ return;
+
+ this._gclueLocationChangedId =
+ this._gclueService.connect('notify::location',
+ this._onGClueLocationChanged.bind(this));
+ this._onGClueLocationChanged();
+ } else {
+ if (this._gclueLocationChangedId)
+ this._gclueService.disconnect(this._gclueLocationChangedId);
+ this._gclueLocationChangedId = 0;
+ }
+ }
+
+ async _startGClueService() {
+ if (this._gclueStarting)
+ return;
+
+ this._gclueStarting = true;
+
+ try {
+ this._gclueService = await Geoclue.Simple.new(
+ 'org.gnome.Shell', Geoclue.AccuracyLevel.CITY, null);
+ } catch (e) {
+ log(`Failed to connect to Geoclue2 service: ${e.message}`);
+ this._setLocation(this._mostRecentLocation);
+ return;
+ }
+ this._gclueStarted = true;
+ this._gclueService.get_client().distance_threshold = 100;
+ this._updateLocationMonitoring();
+ }
+
+ _onGClueLocationChanged() {
+ let geoLocation = this._gclueService.location;
+ // Provide empty name so GWeather sets location name
+ const location = GWeather.Location.new_detached('',
+ null,
+ geoLocation.latitude,
+ geoLocation.longitude);
+ this._setLocation(location);
+ }
+
+ _onAutomaticLocationChanged() {
+ let useAutoLocation = this._settings.get_boolean('automatic-location');
+ if (this._autoLocationRequested == useAutoLocation)
+ return;
+
+ this._autoLocationRequested = useAutoLocation;
+
+ this._updateAutoLocation();
+ }
+
+ _updateAutoLocation() {
+ this._updateLocationMonitoring();
+
+ if (this._useAutoLocation)
+ this._startGClueService();
+ else
+ this._setLocation(this._mostRecentLocation);
+ }
+
+ _onLocationsChanged() {
+ let locations = this._settings.get_value('locations').deepUnpack();
+ let serialized = locations.shift();
+ let mostRecentLocation = null;
+
+ if (serialized)
+ mostRecentLocation = this._world.deserialize(serialized);
+
+ if (this._locationsEqual(this._mostRecentLocation, mostRecentLocation))
+ return;
+
+ this._mostRecentLocation = mostRecentLocation;
+
+ if (!this._useAutoLocation || !this._gclueStarted)
+ this._setLocation(this._mostRecentLocation);
+ }
+
+ _onPermStoreChanged(proxy, sender, params) {
+ let [table, id, deleted_, data_, perms] = params;
+
+ if (table != 'gnome' || id != 'geolocation')
+ return;
+
+ let permission = perms['org.gnome.Weather'] || ['NONE'];
+ let [accuracy] = permission;
+ this._weatherAuthorized = accuracy != 'NONE';
+
+ this._updateAutoLocation();
+ }
+};