diff options
Diffstat (limited to '')
-rw-r--r-- | js/misc/config.js.in | 23 | ||||
-rw-r--r-- | js/misc/dbusUtils.js | 68 | ||||
-rw-r--r-- | js/misc/extensionUtils.js | 318 | ||||
-rw-r--r-- | js/misc/fileUtils.js | 68 | ||||
-rw-r--r-- | js/misc/gnomeSession.js | 45 | ||||
-rw-r--r-- | js/misc/history.js | 114 | ||||
-rw-r--r-- | js/misc/ibusManager.js | 398 | ||||
-rw-r--r-- | js/misc/inputMethod.js | 386 | ||||
-rw-r--r-- | js/misc/introspect.js | 217 | ||||
-rw-r--r-- | js/misc/jsParse.js | 236 | ||||
-rw-r--r-- | js/misc/keyboardManager.js | 163 | ||||
-rw-r--r-- | js/misc/loginManager.js | 247 | ||||
-rw-r--r-- | js/misc/meson.build | 15 | ||||
-rw-r--r-- | js/misc/modemManager.js | 298 | ||||
-rw-r--r-- | js/misc/objectManager.js | 261 | ||||
-rw-r--r-- | js/misc/params.js | 28 | ||||
-rw-r--r-- | js/misc/parentalControlsManager.js | 153 | ||||
-rw-r--r-- | js/misc/permissionStore.js | 16 | ||||
-rw-r--r-- | js/misc/signalTracker.js | 269 | ||||
-rw-r--r-- | js/misc/signals.js | 22 | ||||
-rw-r--r-- | js/misc/smartcardManager.js | 119 | ||||
-rw-r--r-- | js/misc/systemActions.js | 474 | ||||
-rw-r--r-- | js/misc/util.js | 617 | ||||
-rw-r--r-- | js/misc/weather.js | 326 |
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(); + } +}; |