diff options
Diffstat (limited to 'js/ui/status')
-rw-r--r-- | js/ui/status/accessibility.js | 200 | ||||
-rw-r--r-- | js/ui/status/bluetooth.js | 158 | ||||
-rw-r--r-- | js/ui/status/brightness.js | 73 | ||||
-rw-r--r-- | js/ui/status/dwellClick.js | 86 | ||||
-rw-r--r-- | js/ui/status/keyboard.js | 1079 | ||||
-rw-r--r-- | js/ui/status/location.js | 387 | ||||
-rw-r--r-- | js/ui/status/network.js | 2101 | ||||
-rw-r--r-- | js/ui/status/nightLight.js | 70 | ||||
-rw-r--r-- | js/ui/status/power.js | 155 | ||||
-rw-r--r-- | js/ui/status/remoteAccess.js | 97 | ||||
-rw-r--r-- | js/ui/status/rfkill.js | 112 | ||||
-rw-r--r-- | js/ui/status/system.js | 178 | ||||
-rw-r--r-- | js/ui/status/thunderbolt.js | 340 | ||||
-rw-r--r-- | js/ui/status/volume.js | 430 |
14 files changed, 5466 insertions, 0 deletions
diff --git a/js/ui/status/accessibility.js b/js/ui/status/accessibility.js new file mode 100644 index 0000000..04406e2 --- /dev/null +++ b/js/ui/status/accessibility.js @@ -0,0 +1,200 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ATIndicator */ + +const { Gio, GLib, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const A11Y_SCHEMA = 'org.gnome.desktop.a11y'; +const KEY_ALWAYS_SHOW = 'always-show-universal-access-status'; + +const A11Y_KEYBOARD_SCHEMA = 'org.gnome.desktop.a11y.keyboard'; +const KEY_STICKY_KEYS_ENABLED = 'stickykeys-enable'; +const KEY_BOUNCE_KEYS_ENABLED = 'bouncekeys-enable'; +const KEY_SLOW_KEYS_ENABLED = 'slowkeys-enable'; +const KEY_MOUSE_KEYS_ENABLED = 'mousekeys-enable'; + +const APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications'; + +var DPI_FACTOR_LARGE = 1.25; + +const WM_SCHEMA = 'org.gnome.desktop.wm.preferences'; +const KEY_VISUAL_BELL = 'visual-bell'; + +const DESKTOP_INTERFACE_SCHEMA = 'org.gnome.desktop.interface'; +const KEY_GTK_THEME = 'gtk-theme'; +const KEY_ICON_THEME = 'icon-theme'; +const KEY_TEXT_SCALING_FACTOR = 'text-scaling-factor'; + +const HIGH_CONTRAST_THEME = 'HighContrast'; + +var ATIndicator = GObject.registerClass( +class ATIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Accessibility")); + + this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + this._hbox.add_child(new St.Icon({ style_class: 'system-status-icon', + icon_name: 'preferences-desktop-accessibility-symbolic' })); + this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM)); + + this.add_child(this._hbox); + + this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA }); + this._a11ySettings.connect('changed::%s'.format(KEY_ALWAYS_SHOW), this._queueSyncMenuVisibility.bind(this)); + + let highContrast = this._buildHCItem(); + this.menu.addMenuItem(highContrast); + + let magnifier = this._buildItem(_("Zoom"), APPLICATIONS_SCHEMA, + 'screen-magnifier-enabled'); + this.menu.addMenuItem(magnifier); + + let textZoom = this._buildFontItem(); + this.menu.addMenuItem(textZoom); + + let screenReader = this._buildItem(_("Screen Reader"), APPLICATIONS_SCHEMA, + 'screen-reader-enabled'); + this.menu.addMenuItem(screenReader); + + let screenKeyboard = this._buildItem(_("Screen Keyboard"), APPLICATIONS_SCHEMA, + 'screen-keyboard-enabled'); + this.menu.addMenuItem(screenKeyboard); + + let visualBell = this._buildItem(_("Visual Alerts"), WM_SCHEMA, KEY_VISUAL_BELL); + this.menu.addMenuItem(visualBell); + + let stickyKeys = this._buildItem(_("Sticky Keys"), A11Y_KEYBOARD_SCHEMA, KEY_STICKY_KEYS_ENABLED); + this.menu.addMenuItem(stickyKeys); + + let slowKeys = this._buildItem(_("Slow Keys"), A11Y_KEYBOARD_SCHEMA, KEY_SLOW_KEYS_ENABLED); + this.menu.addMenuItem(slowKeys); + + let bounceKeys = this._buildItem(_("Bounce Keys"), A11Y_KEYBOARD_SCHEMA, KEY_BOUNCE_KEYS_ENABLED); + this.menu.addMenuItem(bounceKeys); + + let mouseKeys = this._buildItem(_("Mouse Keys"), A11Y_KEYBOARD_SCHEMA, KEY_MOUSE_KEYS_ENABLED); + this.menu.addMenuItem(mouseKeys); + + this._syncMenuVisibility(); + } + + _syncMenuVisibility() { + this._syncMenuVisibilityIdle = 0; + + let alwaysShow = this._a11ySettings.get_boolean(KEY_ALWAYS_SHOW); + let items = this.menu._getMenuItems(); + + this.visible = alwaysShow || items.some(f => !!f.state); + + return GLib.SOURCE_REMOVE; + } + + _queueSyncMenuVisibility() { + if (this._syncMenuVisibilityIdle) + return; + + this._syncMenuVisibilityIdle = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._syncMenuVisibility.bind(this)); + GLib.Source.set_name_by_id(this._syncMenuVisibilityIdle, '[gnome-shell] this._syncMenuVisibility'); + } + + _buildItemExtended(string, initialValue, writable, onSet) { + let widget = new PopupMenu.PopupSwitchMenuItem(string, initialValue); + if (!writable) { + widget.reactive = false; + } else { + widget.connect('toggled', item => { + onSet(item.state); + }); + } + return widget; + } + + _buildItem(string, schema, key) { + let settings = new Gio.Settings({ schema_id: schema }); + let widget = this._buildItemExtended(string, + settings.get_boolean(key), + settings.is_writable(key), + enabled => settings.set_boolean(key, enabled)); + + settings.connect('changed::%s'.format(key), () => { + widget.setToggleState(settings.get_boolean(key)); + + this._queueSyncMenuVisibility(); + }); + + return widget; + } + + _buildHCItem() { + let interfaceSettings = new Gio.Settings({ schema_id: DESKTOP_INTERFACE_SCHEMA }); + let gtkTheme = interfaceSettings.get_string(KEY_GTK_THEME); + let iconTheme = interfaceSettings.get_string(KEY_ICON_THEME); + let hasHC = gtkTheme == HIGH_CONTRAST_THEME; + let highContrast = this._buildItemExtended( + _("High Contrast"), + hasHC, + interfaceSettings.is_writable(KEY_GTK_THEME) && + interfaceSettings.is_writable(KEY_ICON_THEME), + enabled => { + if (enabled) { + interfaceSettings.set_string(KEY_ICON_THEME, HIGH_CONTRAST_THEME); + interfaceSettings.set_string(KEY_GTK_THEME, HIGH_CONTRAST_THEME); + } else if (!hasHC) { + interfaceSettings.set_string(KEY_ICON_THEME, iconTheme); + interfaceSettings.set_string(KEY_GTK_THEME, gtkTheme); + } else { + interfaceSettings.reset(KEY_ICON_THEME); + interfaceSettings.reset(KEY_GTK_THEME); + } + }); + + interfaceSettings.connect('changed::%s'.format(KEY_GTK_THEME), () => { + let value = interfaceSettings.get_string(KEY_GTK_THEME); + if (value == HIGH_CONTRAST_THEME) { + highContrast.setToggleState(true); + } else { + highContrast.setToggleState(false); + gtkTheme = value; + } + + this._queueSyncMenuVisibility(); + }); + + interfaceSettings.connect('changed::%s'.format(KEY_ICON_THEME), () => { + let value = interfaceSettings.get_string(KEY_ICON_THEME); + if (value != HIGH_CONTRAST_THEME) + iconTheme = value; + }); + + return highContrast; + } + + _buildFontItem() { + let settings = new Gio.Settings({ schema_id: DESKTOP_INTERFACE_SCHEMA }); + let factor = settings.get_double(KEY_TEXT_SCALING_FACTOR); + let initialSetting = factor > 1.0; + let widget = this._buildItemExtended(_("Large Text"), + initialSetting, + settings.is_writable(KEY_TEXT_SCALING_FACTOR), + enabled => { + if (enabled) { + settings.set_double( + KEY_TEXT_SCALING_FACTOR, DPI_FACTOR_LARGE); + } else { + settings.reset(KEY_TEXT_SCALING_FACTOR); + } + }); + + settings.connect('changed::%s'.format(KEY_TEXT_SCALING_FACTOR), () => { + factor = settings.get_double(KEY_TEXT_SCALING_FACTOR); + let active = factor > 1.0; + widget.setToggleState(active); + + this._queueSyncMenuVisibility(); + }); + + return widget; + } +}); diff --git a/js/ui/status/bluetooth.js b/js/ui/status/bluetooth.js new file mode 100644 index 0000000..98ccc3d --- /dev/null +++ b/js/ui/status/bluetooth.js @@ -0,0 +1,158 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GLib, GnomeBluetooth, GObject } = imports.gi; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill'; + +const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill'); +const RfkillManagerProxy = Gio.DBusProxy.makeProxyWrapper(RfkillManagerInterface); + +const HAD_BLUETOOTH_DEVICES_SETUP = 'had-bluetooth-devices-setup'; + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'bluetooth-active-symbolic'; + this._hadSetupDevices = global.settings.get_boolean(HAD_BLUETOOTH_DEVICES_SETUP); + + this._proxy = new RfkillManagerProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + + this._sync(); + }); + this._proxy.connect('g-properties-changed', this._queueSync.bind(this)); + + this._item = new PopupMenu.PopupSubMenuMenuItem(_("Bluetooth"), true); + this._item.icon.icon_name = 'bluetooth-active-symbolic'; + + this._toggleItem = new PopupMenu.PopupMenuItem(''); + this._toggleItem.connect('activate', () => { + this._proxy.BluetoothAirplaneMode = !this._proxy.BluetoothAirplaneMode; + }); + this._item.menu.addMenuItem(this._toggleItem); + + this._item.menu.addSettingsAction(_("Bluetooth Settings"), 'gnome-bluetooth-panel.desktop'); + this.menu.addMenuItem(this._item); + + this._syncId = 0; + this._adapter = null; + + this._client = new GnomeBluetooth.Client(); + this._model = this._client.get_model(); + this._model.connect('row-deleted', this._queueSync.bind(this)); + this._model.connect('row-changed', this._queueSync.bind(this)); + this._model.connect('row-inserted', this._sync.bind(this)); + Main.sessionMode.connect('updated', this._sync.bind(this)); + this._sync(); + } + + _setHadSetupDevices(value) { + if (this._hadSetupDevices === value) + return; + + this._hadSetupDevices = value; + global.settings.set_boolean( + HAD_BLUETOOTH_DEVICES_SETUP, this._hadSetupDevices); + } + + _getDefaultAdapter() { + let [ret, iter] = this._model.get_iter_first(); + while (ret) { + let isDefault = this._model.get_value(iter, + GnomeBluetooth.Column.DEFAULT); + let isPowered = this._model.get_value(iter, + GnomeBluetooth.Column.POWERED); + if (isDefault && isPowered) + return iter; + ret = this._model.iter_next(iter); + } + return null; + } + + _getDeviceInfos(adapter) { + if (!adapter) + return []; + + let deviceInfos = []; + let [ret, iter] = this._model.iter_children(adapter); + while (ret) { + const isPaired = this._model.get_value(iter, + GnomeBluetooth.Column.PAIRED); + const isTrusted = this._model.get_value(iter, + GnomeBluetooth.Column.TRUSTED); + + if (isPaired || isTrusted) { + deviceInfos.push({ + connected: this._model.get_value(iter, + GnomeBluetooth.Column.CONNECTED), + name: this._model.get_value(iter, + GnomeBluetooth.Column.ALIAS), + }); + } + + ret = this._model.iter_next(iter); + } + + return deviceInfos; + } + + _queueSync() { + if (this._syncId) + return; + this._syncId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._syncId = 0; + this._sync(); + return GLib.SOURCE_REMOVE; + }); + } + + _sync() { + let adapter = this._getDefaultAdapter(); + let devices = this._getDeviceInfos(adapter); + const connectedDevices = devices.filter(dev => dev.connected); + const nConnectedDevices = connectedDevices.length; + + if (adapter && this._adapter) + this._setHadSetupDevices(devices.length > 0); + this._adapter = adapter; + + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + + this.menu.setSensitive(sensitive); + this._indicator.visible = nConnectedDevices > 0; + + // Remember if there were setup devices and show the menu + // if we've seen setup devices and we're not hard blocked + if (this._hadSetupDevices) + this._item.visible = !this._proxy.BluetoothHardwareAirplaneMode; + else + this._item.visible = this._proxy.BluetoothHasAirplaneMode && !this._proxy.BluetoothAirplaneMode; + + if (nConnectedDevices > 1) + /* Translators: this is the number of connected bluetooth devices */ + this._item.label.text = ngettext('%d Connected', '%d Connected', nConnectedDevices).format(nConnectedDevices); + else if (nConnectedDevices === 1) + this._item.label.text = connectedDevices[0].name; + else if (adapter === null) + this._item.label.text = _('Bluetooth Off'); + else + this._item.label.text = _('Bluetooth On'); + + this._toggleItem.label.text = this._proxy.BluetoothAirplaneMode ? _('Turn On') : _('Turn Off'); + } +}); diff --git a/js/ui/status/brightness.js b/js/ui/status/brightness.js new file mode 100644 index 0000000..1724788 --- /dev/null +++ b/js/ui/status/brightness.js @@ -0,0 +1,73 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const Slider = imports.ui.slider; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Power'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Power'; + +const BrightnessInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Power.Screen'); +const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper(BrightnessInterface); + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + this._proxy = new BrightnessProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + + this._proxy.connect('g-properties-changed', this._sync.bind(this)); + this._sync(); + }); + + this._item = new PopupMenu.PopupBaseMenuItem({ activate: false }); + this.menu.addMenuItem(this._item); + + this._slider = new Slider.Slider(0); + this._sliderChangedId = this._slider.connect('notify::value', + this._sliderChanged.bind(this)); + this._slider.accessible_name = _("Brightness"); + + let icon = new St.Icon({ icon_name: 'display-brightness-symbolic', + style_class: 'popup-menu-icon' }); + this._item.add(icon); + this._item.add_child(this._slider); + this._item.connect('button-press-event', (actor, event) => { + return this._slider.startDragging(event); + }); + this._item.connect('key-press-event', (actor, event) => { + return this._slider.emit('key-press-event', event); + }); + this._item.connect('scroll-event', (actor, event) => { + return this._slider.emit('scroll-event', event); + }); + } + + _sliderChanged() { + let percent = this._slider.value * 100; + this._proxy.Brightness = percent; + } + + _changeSlider(value) { + this._slider.block_signal_handler(this._sliderChangedId); + this._slider.value = value; + this._slider.unblock_signal_handler(this._sliderChangedId); + } + + _sync() { + let visible = this._proxy.Brightness >= 0; + this._item.visible = visible; + if (visible) + this._changeSlider(this._proxy.Brightness / 100.0); + } +}); diff --git a/js/ui/status/dwellClick.js b/js/ui/status/dwellClick.js new file mode 100644 index 0000000..ce13f73 --- /dev/null +++ b/js/ui/status/dwellClick.js @@ -0,0 +1,86 @@ +/* exported DwellClickIndicator */ +const { Clutter, Gio, GLib, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const MOUSE_A11Y_SCHEMA = 'org.gnome.desktop.a11y.mouse'; +const KEY_DWELL_CLICK_ENABLED = 'dwell-click-enabled'; +const KEY_DWELL_MODE = 'dwell-mode'; +const DWELL_MODE_WINDOW = 'window'; +const DWELL_CLICK_MODES = { + primary: { + name: _("Single Click"), + icon: 'pointer-primary-click-symbolic', + type: Clutter.PointerA11yDwellClickType.PRIMARY, + }, + double: { + name: _("Double Click"), + icon: 'pointer-double-click-symbolic', + type: Clutter.PointerA11yDwellClickType.DOUBLE, + }, + drag: { + name: _("Drag"), + icon: 'pointer-drag-symbolic', + type: Clutter.PointerA11yDwellClickType.DRAG, + }, + secondary: { + name: _("Secondary Click"), + icon: 'pointer-secondary-click-symbolic', + type: Clutter.PointerA11yDwellClickType.SECONDARY, + }, +}; + +var DwellClickIndicator = GObject.registerClass( +class DwellClickIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Dwell Click")); + + this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + this._icon = new St.Icon({ style_class: 'system-status-icon', + icon_name: 'pointer-primary-click-symbolic' }); + this._hbox.add_child(this._icon); + this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM)); + + this.add_child(this._hbox); + + this._a11ySettings = new Gio.Settings({ schema_id: MOUSE_A11Y_SCHEMA }); + this._a11ySettings.connect('changed::%s'.format(KEY_DWELL_CLICK_ENABLED), this._syncMenuVisibility.bind(this)); + this._a11ySettings.connect('changed::%s'.format(KEY_DWELL_MODE), this._syncMenuVisibility.bind(this)); + + this._seat = Clutter.get_default_backend().get_default_seat(); + this._seat.connect('ptr-a11y-dwell-click-type-changed', this._updateClickType.bind(this)); + + this._addDwellAction(DWELL_CLICK_MODES.primary); + this._addDwellAction(DWELL_CLICK_MODES.double); + this._addDwellAction(DWELL_CLICK_MODES.drag); + this._addDwellAction(DWELL_CLICK_MODES.secondary); + + this._setClickType(DWELL_CLICK_MODES.primary); + this._syncMenuVisibility(); + } + + _syncMenuVisibility() { + this.visible = + this._a11ySettings.get_boolean(KEY_DWELL_CLICK_ENABLED) && + this._a11ySettings.get_string(KEY_DWELL_MODE) == DWELL_MODE_WINDOW; + + return GLib.SOURCE_REMOVE; + } + + _addDwellAction(mode) { + this.menu.addAction(mode.name, this._setClickType.bind(this, mode), mode.icon); + } + + _updateClickType(manager, clickType) { + for (let mode in DWELL_CLICK_MODES) { + if (DWELL_CLICK_MODES[mode].type == clickType) + this._icon.icon_name = DWELL_CLICK_MODES[mode].icon; + } + } + + _setClickType(mode) { + this._seat.set_pointer_a11y_dwell_click_type(mode.type); + this._icon.icon_name = mode.icon; + } +}); diff --git a/js/ui/status/keyboard.js b/js/ui/status/keyboard.js new file mode 100644 index 0000000..43fd89d --- /dev/null +++ b/js/ui/status/keyboard.js @@ -0,0 +1,1079 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported InputSourceIndicator */ + +const { Clutter, Gio, GLib, GObject, IBus, Meta, Shell, St } = imports.gi; +const Gettext = imports.gettext; +const Signals = imports.signals; + +const IBusManager = imports.misc.ibusManager; +const KeyboardManager = imports.misc.keyboardManager; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const PanelMenu = imports.ui.panelMenu; +const SwitcherPopup = imports.ui.switcherPopup; +const Util = imports.misc.util; + +var INPUT_SOURCE_TYPE_XKB = 'xkb'; +var INPUT_SOURCE_TYPE_IBUS = 'ibus'; + +var LayoutMenuItem = GObject.registerClass( +class LayoutMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(displayName, shortName) { + super._init(); + + this.label = new St.Label({ + text: displayName, + x_expand: true, + }); + this.indicator = new St.Label({ text: shortName }); + this.add_child(this.label); + this.add(this.indicator); + this.label_actor = this.label; + } +}); + +var InputSource = class { + constructor(type, id, displayName, shortName, index) { + this.type = type; + this.id = id; + this.displayName = displayName; + this._shortName = shortName; + this.index = index; + + this.properties = null; + + this.xkbId = this._getXkbId(); + } + + get shortName() { + return this._shortName; + } + + set shortName(v) { + this._shortName = v; + this.emit('changed'); + } + + activate(interactive) { + this.emit('activate', !!interactive); + } + + _getXkbId() { + let engineDesc = IBusManager.getIBusManager().getEngineDesc(this.id); + if (!engineDesc) + return this.id; + + if (engineDesc.variant && engineDesc.variant.length > 0) + return '%s+%s'.format(engineDesc.layout, engineDesc.variant); + else + return engineDesc.layout; + } +}; +Signals.addSignalMethods(InputSource.prototype); + +var InputSourcePopup = GObject.registerClass( +class InputSourcePopup extends SwitcherPopup.SwitcherPopup { + _init(items, action, actionBackward) { + super._init(items); + + this._action = action; + this._actionBackward = actionBackward; + + this._switcherList = new InputSourceSwitcher(this._items); + } + + _keyPressHandler(keysym, action) { + if (action == this._action) + this._select(this._next()); + else if (action == this._actionBackward) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish() { + super._finish(); + + this._items[this._selectedIndex].activate(true); + } +}); + +var InputSourceSwitcher = GObject.registerClass( +class InputSourceSwitcher extends SwitcherPopup.SwitcherList { + _init(items) { + super._init(true); + + for (let i = 0; i < items.length; i++) + this._addIcon(items[i]); + } + + _addIcon(item) { + let box = new St.BoxLayout({ vertical: true }); + + let bin = new St.Bin({ style_class: 'input-source-switcher-symbol' }); + let symbol = new St.Label({ + text: item.shortName, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + bin.set_child(symbol); + box.add_child(bin); + + let text = new St.Label({ + text: item.displayName, + x_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(text); + + this.addItem(box, text); + } +}); + +var InputSourceSettings = class { + constructor() { + if (this.constructor === InputSourceSettings) + throw new TypeError('Cannot instantiate abstract class %s'.format(this.constructor.name)); + } + + _emitInputSourcesChanged() { + this.emit('input-sources-changed'); + } + + _emitKeyboardOptionsChanged() { + this.emit('keyboard-options-changed'); + } + + _emitPerWindowChanged() { + this.emit('per-window-changed'); + } + + get inputSources() { + return []; + } + + get mruSources() { + return []; + } + + set mruSources(sourcesList) { + // do nothing + } + + get keyboardOptions() { + return []; + } + + get perWindow() { + return false; + } +}; +Signals.addSignalMethods(InputSourceSettings.prototype); + +var InputSourceSystemSettings = class extends InputSourceSettings { + constructor() { + super(); + + this._BUS_NAME = 'org.freedesktop.locale1'; + this._BUS_PATH = '/org/freedesktop/locale1'; + this._BUS_IFACE = 'org.freedesktop.locale1'; + this._BUS_PROPS_IFACE = 'org.freedesktop.DBus.Properties'; + + this._layouts = ''; + this._variants = ''; + this._options = ''; + + this._reload(); + + Gio.DBus.system.signal_subscribe(this._BUS_NAME, + this._BUS_PROPS_IFACE, + 'PropertiesChanged', + this._BUS_PATH, + null, + Gio.DBusSignalFlags.NONE, + this._reload.bind(this)); + } + + async _reload() { + let props; + try { + const result = await Gio.DBus.system.call( + this._BUS_NAME, + this._BUS_PATH, + this._BUS_PROPS_IFACE, + 'GetAll', + new GLib.Variant('(s)', [this._BUS_IFACE]), + null, Gio.DBusCallFlags.NONE, -1, null); + [props] = result.deep_unpack(); + } catch (e) { + log('Could not get properties from %s'.format(this._BUS_NAME)); + return; + } + + const layouts = props['X11Layout'].unpack(); + const variants = props['X11Variant'].unpack(); + const options = props['X11Options'].unpack(); + + if (layouts !== this._layouts || + variants !== this._variants) { + this._layouts = layouts; + this._variants = variants; + this._emitInputSourcesChanged(); + } + if (options !== this._options) { + this._options = options; + this._emitKeyboardOptionsChanged(); + } + } + + get inputSources() { + let sourcesList = []; + let layouts = this._layouts.split(','); + let variants = this._variants.split(','); + + for (let i = 0; i < layouts.length && !!layouts[i]; i++) { + let id = layouts[i]; + if (variants[i]) + id += '+%s'.format(variants[i]); + sourcesList.push({ type: INPUT_SOURCE_TYPE_XKB, id }); + } + return sourcesList; + } + + get keyboardOptions() { + return this._options.split(','); + } +}; + +var InputSourceSessionSettings = class extends InputSourceSettings { + constructor() { + super(); + + this._DESKTOP_INPUT_SOURCES_SCHEMA = 'org.gnome.desktop.input-sources'; + this._KEY_INPUT_SOURCES = 'sources'; + this._KEY_MRU_SOURCES = 'mru-sources'; + this._KEY_KEYBOARD_OPTIONS = 'xkb-options'; + this._KEY_PER_WINDOW = 'per-window'; + + this._settings = new Gio.Settings({ schema_id: this._DESKTOP_INPUT_SOURCES_SCHEMA }); + this._settings.connect('changed::%s'.format(this._KEY_INPUT_SOURCES), this._emitInputSourcesChanged.bind(this)); + this._settings.connect('changed::%s'.format(this._KEY_KEYBOARD_OPTIONS), this._emitKeyboardOptionsChanged.bind(this)); + this._settings.connect('changed::%s'.format(this._KEY_PER_WINDOW), this._emitPerWindowChanged.bind(this)); + } + + _getSourcesList(key) { + let sourcesList = []; + let sources = this._settings.get_value(key); + let nSources = sources.n_children(); + + for (let i = 0; i < nSources; i++) { + let [type, id] = sources.get_child_value(i).deep_unpack(); + sourcesList.push({ type, id }); + } + return sourcesList; + } + + get inputSources() { + return this._getSourcesList(this._KEY_INPUT_SOURCES); + } + + get mruSources() { + return this._getSourcesList(this._KEY_MRU_SOURCES); + } + + set mruSources(sourcesList) { + let sources = GLib.Variant.new('a(ss)', sourcesList); + this._settings.set_value(this._KEY_MRU_SOURCES, sources); + } + + get keyboardOptions() { + return this._settings.get_strv(this._KEY_KEYBOARD_OPTIONS); + } + + get perWindow() { + return this._settings.get_boolean(this._KEY_PER_WINDOW); + } +}; + +var InputSourceManager = class { + constructor() { + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list indexed by their index there + this._inputSources = {}; + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list of type INPUT_SOURCE_TYPE_IBUS + // indexed by the IBus ID + this._ibusSources = {}; + + this._currentSource = null; + + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list ordered by most recently used + this._mruSources = []; + this._mruSourcesBackup = null; + this._keybindingAction = + Main.wm.addKeybinding('switch-input-source', + new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }), + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.ALL, + this._switchInputSource.bind(this)); + this._keybindingActionBackward = + Main.wm.addKeybinding('switch-input-source-backward', + new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }), + Meta.KeyBindingFlags.IS_REVERSED, + Shell.ActionMode.ALL, + this._switchInputSource.bind(this)); + if (Main.sessionMode.isGreeter) + this._settings = new InputSourceSystemSettings(); + else + this._settings = new InputSourceSessionSettings(); + this._settings.connect('input-sources-changed', this._inputSourcesChanged.bind(this)); + this._settings.connect('keyboard-options-changed', this._keyboardOptionsChanged.bind(this)); + + this._xkbInfo = KeyboardManager.getXkbInfo(); + this._keyboardManager = KeyboardManager.getKeyboardManager(); + + this._ibusReady = false; + this._ibusManager = IBusManager.getIBusManager(); + this._ibusManager.connect('ready', this._ibusReadyCallback.bind(this)); + this._ibusManager.connect('properties-registered', this._ibusPropertiesRegistered.bind(this)); + this._ibusManager.connect('property-updated', this._ibusPropertyUpdated.bind(this)); + this._ibusManager.connect('set-content-type', this._ibusSetContentType.bind(this)); + + global.display.connect('modifiers-accelerator-activated', this._modifiersSwitcher.bind(this)); + + this._sourcesPerWindow = false; + this._focusWindowNotifyId = 0; + this._overviewShowingId = 0; + this._overviewHiddenId = 0; + this._settings.connect('per-window-changed', this._sourcesPerWindowChanged.bind(this)); + this._sourcesPerWindowChanged(); + this._disableIBus = false; + this._reloading = false; + } + + reload() { + this._reloading = true; + this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions); + this._inputSourcesChanged(); + this._reloading = false; + } + + _ibusReadyCallback(im, ready) { + if (this._ibusReady == ready) + return; + + this._ibusReady = ready; + this._mruSources = []; + this._inputSourcesChanged(); + } + + _modifiersSwitcher() { + let sourceIndexes = Object.keys(this._inputSources); + if (sourceIndexes.length == 0) { + KeyboardManager.releaseKeyboard(); + return true; + } + + let is = this._currentSource; + if (!is) + is = this._inputSources[sourceIndexes[0]]; + + let nextIndex = is.index + 1; + if (nextIndex > sourceIndexes[sourceIndexes.length - 1]) + nextIndex = 0; + + while (!(is = this._inputSources[nextIndex])) + nextIndex += 1; + + is.activate(true); + return true; + } + + _switchInputSource(display, window, binding) { + if (this._mruSources.length < 2) + return; + + // HACK: Fall back on simple input source switching since we + // can't show a popup switcher while a GrabHelper grab is in + // effect without considerable work to consolidate the usage + // of pushModal/popModal and grabHelper. See + // https://bugzilla.gnome.org/show_bug.cgi?id=695143 . + if (Main.actionMode == Shell.ActionMode.POPUP) { + this._modifiersSwitcher(); + return; + } + + let popup = new InputSourcePopup(this._mruSources, this._keybindingAction, this._keybindingActionBackward); + if (!popup.show(binding.is_reversed(), binding.get_name(), binding.get_mask())) + popup.fadeAndDestroy(); + } + + _keyboardOptionsChanged() { + this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions); + this._keyboardManager.reapply(); + } + + _updateMruSettings() { + // If IBus is not ready we don't have a full picture of all + // the available sources, so don't update the setting + if (!this._ibusReady) + return; + + // If IBus is temporarily disabled, don't update the setting + if (this._disableIBus) + return; + + let sourcesList = []; + for (let i = 0; i < this._mruSources.length; ++i) { + let source = this._mruSources[i]; + sourcesList.push([source.type, source.id]); + } + + this._settings.mruSources = sourcesList; + } + + _currentInputSourceChanged(newSource) { + let oldSource; + [oldSource, this._currentSource] = [this._currentSource, newSource]; + + this.emit('current-source-changed', oldSource); + + for (let i = 1; i < this._mruSources.length; ++i) { + if (this._mruSources[i] == newSource) { + let currentSource = this._mruSources.splice(i, 1); + this._mruSources = currentSource.concat(this._mruSources); + break; + } + } + this._changePerWindowSource(); + } + + activateInputSource(is, interactive) { + // The focus changes during holdKeyboard/releaseKeyboard may trick + // the client into hiding UI containing the currently focused entry. + // So holdKeyboard/releaseKeyboard are not called when + // 'set-content-type' signal is received. + // E.g. Focusing on a password entry in a popup in Xorg Firefox + // will emit 'set-content-type' signal. + // https://gitlab.gnome.org/GNOME/gnome-shell/issues/391 + if (!this._reloading) + KeyboardManager.holdKeyboard(); + this._keyboardManager.apply(is.xkbId); + + // All the "xkb:..." IBus engines simply "echo" back symbols, + // despite their naming implying differently, so we always set + // one in order for XIM applications to work given that we set + // XMODIFIERS=@im=ibus in the first place so that they can + // work without restarting when/if the user adds an IBus input + // source. + let engine; + if (is.type == INPUT_SOURCE_TYPE_IBUS) + engine = is.id; + else + engine = 'xkb:us::eng'; + + if (!this._reloading) + this._ibusManager.setEngine(engine, KeyboardManager.releaseKeyboard); + else + this._ibusManager.setEngine(engine); + this._currentInputSourceChanged(is); + + if (interactive) + this._updateMruSettings(); + } + + _updateMruSources() { + let sourcesList = []; + for (let i in this._inputSources) + sourcesList.push(this._inputSources[i]); + + this._keyboardManager.setUserLayouts(sourcesList.map(x => x.xkbId)); + + if (!this._disableIBus && this._mruSourcesBackup) { + this._mruSources = this._mruSourcesBackup; + this._mruSourcesBackup = null; + } + + // Initialize from settings when we have no MRU sources list + if (this._mruSources.length == 0) { + let mruSettings = this._settings.mruSources; + for (let i = 0; i < mruSettings.length; i++) { + let mruSettingSource = mruSettings[i]; + let mruSource = null; + + for (let j = 0; j < sourcesList.length; j++) { + let source = sourcesList[j]; + if (source.type == mruSettingSource.type && + source.id == mruSettingSource.id) { + mruSource = source; + break; + } + } + + if (mruSource) + this._mruSources.push(mruSource); + } + } + + let mruSources = []; + for (let i = 0; i < this._mruSources.length; i++) { + for (let j = 0; j < sourcesList.length; j++) { + if (this._mruSources[i].type == sourcesList[j].type && + this._mruSources[i].id == sourcesList[j].id) { + mruSources = mruSources.concat(sourcesList.splice(j, 1)); + break; + } + } + } + this._mruSources = mruSources.concat(sourcesList); + } + + _inputSourcesChanged() { + let sources = this._settings.inputSources; + let nSources = sources.length; + + this._currentSource = null; + this._inputSources = {}; + this._ibusSources = {}; + + let infosList = []; + for (let i = 0; i < nSources; i++) { + let displayName; + let shortName; + let type = sources[i].type; + let id = sources[i].id; + let exists = false; + + if (type == INPUT_SOURCE_TYPE_XKB) { + [exists, displayName, shortName] = + this._xkbInfo.get_layout_info(id); + } else if (type == INPUT_SOURCE_TYPE_IBUS) { + if (this._disableIBus) + continue; + let engineDesc = this._ibusManager.getEngineDesc(id); + if (engineDesc) { + let language = IBus.get_language_name(engineDesc.get_language()); + let longName = engineDesc.get_longname(); + let textdomain = engineDesc.get_textdomain(); + if (textdomain != '') + longName = Gettext.dgettext(textdomain, longName); + exists = true; + displayName = '%s (%s)'.format(language, longName); + shortName = this._makeEngineShortName(engineDesc); + } + } + + if (exists) + infosList.push({ type, id, displayName, shortName }); + } + + if (infosList.length == 0) { + let type = INPUT_SOURCE_TYPE_XKB; + let id = KeyboardManager.DEFAULT_LAYOUT; + let [, displayName, shortName] = this._xkbInfo.get_layout_info(id); + infosList.push({ type, id, displayName, shortName }); + } + + let inputSourcesByShortName = {}; + for (let i = 0; i < infosList.length; i++) { + let is = new InputSource(infosList[i].type, + infosList[i].id, + infosList[i].displayName, + infosList[i].shortName, + i); + is.connect('activate', this.activateInputSource.bind(this)); + + if (!(is.shortName in inputSourcesByShortName)) + inputSourcesByShortName[is.shortName] = []; + inputSourcesByShortName[is.shortName].push(is); + + this._inputSources[is.index] = is; + + if (is.type == INPUT_SOURCE_TYPE_IBUS) + this._ibusSources[is.id] = is; + } + + for (let i in this._inputSources) { + let is = this._inputSources[i]; + if (inputSourcesByShortName[is.shortName].length > 1) { + let sub = inputSourcesByShortName[is.shortName].indexOf(is) + 1; + is.shortName += String.fromCharCode(0x2080 + sub); + } + } + + this.emit('sources-changed'); + + this._updateMruSources(); + + if (this._mruSources.length > 0) + this._mruSources[0].activate(false); + + // All ibus engines are preloaded here to reduce the launching time + // when users switch the input sources. + this._ibusManager.preloadEngines(Object.keys(this._ibusSources)); + } + + _makeEngineShortName(engineDesc) { + let symbol = engineDesc.get_symbol(); + if (symbol && symbol[0]) + return symbol; + + let langCode = engineDesc.get_language().split('_', 1)[0]; + if (langCode.length == 2 || langCode.length == 3) + return langCode.toLowerCase(); + + return String.fromCharCode(0x2328); // keyboard glyph + } + + _ibusPropertiesRegistered(im, engineName, props) { + let source = this._ibusSources[engineName]; + if (!source) + return; + + source.properties = props; + + if (source == this._currentSource) + this.emit('current-source-changed', null); + } + + _ibusPropertyUpdated(im, engineName, prop) { + let source = this._ibusSources[engineName]; + if (!source) + return; + + if (this._updateSubProperty(source.properties, prop) && + source == this._currentSource) + this.emit('current-source-changed', null); + } + + _updateSubProperty(props, prop) { + if (!props) + return false; + + let p; + for (let i = 0; (p = props.get(i)) != null; ++i) { + if (p.get_key() == prop.get_key() && p.get_prop_type() == prop.get_prop_type()) { + p.update(prop); + return true; + } else if (p.get_prop_type() == IBus.PropType.MENU) { + if (this._updateSubProperty(p.get_sub_props(), prop)) + return true; + } + } + return false; + } + + _ibusSetContentType(im, purpose, _hints) { + if (purpose == IBus.InputPurpose.PASSWORD) { + if (Object.keys(this._inputSources).length == Object.keys(this._ibusSources).length) + return; + + if (this._disableIBus) + return; + this._disableIBus = true; + this._mruSourcesBackup = this._mruSources.slice(); + } else { + if (!this._disableIBus) + return; + this._disableIBus = false; + } + this.reload(); + } + + _getNewInputSource(current) { + let sourceIndexes = Object.keys(this._inputSources); + if (sourceIndexes.length == 0) + return null; + + if (current) { + for (let i in this._inputSources) { + let is = this._inputSources[i]; + if (is.type == current.type && + is.id == current.id) + return is; + } + } + + return this._inputSources[sourceIndexes[0]]; + } + + _getCurrentWindow() { + if (Main.overview.visible) + return Main.overview; + else + return global.display.focus_window; + } + + _setPerWindowInputSource() { + let window = this._getCurrentWindow(); + if (!window) + return; + + if (!window._inputSources || + window._inputSources !== this._inputSources) { + window._inputSources = this._inputSources; + window._currentSource = this._getNewInputSource(window._currentSource); + } + + if (window._currentSource) + window._currentSource.activate(false); + } + + _sourcesPerWindowChanged() { + this._sourcesPerWindow = this._settings.perWindow; + + if (this._sourcesPerWindow && this._focusWindowNotifyId == 0) { + this._focusWindowNotifyId = global.display.connect('notify::focus-window', + this._setPerWindowInputSource.bind(this)); + this._overviewShowingId = Main.overview.connect('showing', + this._setPerWindowInputSource.bind(this)); + this._overviewHiddenId = Main.overview.connect('hidden', + this._setPerWindowInputSource.bind(this)); + } else if (!this._sourcesPerWindow && this._focusWindowNotifyId != 0) { + global.display.disconnect(this._focusWindowNotifyId); + this._focusWindowNotifyId = 0; + Main.overview.disconnect(this._overviewShowingId); + this._overviewShowingId = 0; + Main.overview.disconnect(this._overviewHiddenId); + this._overviewHiddenId = 0; + + let windows = global.get_window_actors().map(w => w.meta_window); + for (let i = 0; i < windows.length; ++i) { + delete windows[i]._inputSources; + delete windows[i]._currentSource; + } + delete Main.overview._inputSources; + delete Main.overview._currentSource; + } + } + + _changePerWindowSource() { + if (!this._sourcesPerWindow) + return; + + let window = this._getCurrentWindow(); + if (!window) + return; + + window._inputSources = this._inputSources; + window._currentSource = this._currentSource; + } + + get currentSource() { + return this._currentSource; + } + + get inputSources() { + return this._inputSources; + } +}; +Signals.addSignalMethods(InputSourceManager.prototype); + +let _inputSourceManager = null; + +function getInputSourceManager() { + if (_inputSourceManager == null) + _inputSourceManager = new InputSourceManager(); + return _inputSourceManager; +} + +var InputSourceIndicatorContainer = GObject.registerClass( +class InputSourceIndicatorContainer extends St.Widget { + + vfunc_get_preferred_width(forHeight) { + // Here, and in vfunc_get_preferred_height, we need to query + // for the height of all children, but we ignore the results + // for those we don't actually display. + return this.get_children().reduce((maxWidth, child) => { + let width = child.get_preferred_width(forHeight); + return [Math.max(maxWidth[0], width[0]), + Math.max(maxWidth[1], width[1])]; + }, [0, 0]); + } + + vfunc_get_preferred_height(forWidth) { + return this.get_children().reduce((maxHeight, child) => { + let height = child.get_preferred_height(forWidth); + return [Math.max(maxHeight[0], height[0]), + Math.max(maxHeight[1], height[1])]; + }, [0, 0]); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + // translate box to (0, 0) + box.x2 -= box.x1; + box.x1 = 0; + box.y2 -= box.y1; + box.y1 = 0; + + this.get_children().forEach(c => { + c.allocate_align_fill(box, 0.5, 0.5, false, false); + }); + } +}); + +var InputSourceIndicator = GObject.registerClass( +class InputSourceIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Keyboard")); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._menuItems = {}; + this._indicatorLabels = {}; + + this._container = new InputSourceIndicatorContainer(); + + this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + this._hbox.add_child(this._container); + this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM)); + + this.add_child(this._hbox); + + this._propSeparator = new PopupMenu.PopupSeparatorMenuItem(); + this.menu.addMenuItem(this._propSeparator); + this._propSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._propSection); + this._propSection.actor.hide(); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._showLayoutItem = this.menu.addAction(_("Show Keyboard Layout"), this._showLayout.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + + this._inputSourceManager = getInputSourceManager(); + this._inputSourceManagerSourcesChangedId = + this._inputSourceManager.connect('sources-changed', this._sourcesChanged.bind(this)); + this._inputSourceManagerCurrentSourceChangedId = + this._inputSourceManager.connect('current-source-changed', this._currentSourceChanged.bind(this)); + this._inputSourceManager.reload(); + } + + _onDestroy() { + if (this._inputSourceManager) { + this._inputSourceManager.disconnect(this._inputSourceManagerSourcesChangedId); + this._inputSourceManager.disconnect(this._inputSourceManagerCurrentSourceChangedId); + this._inputSourceManager = null; + } + } + + _sessionUpdated() { + // re-using "allowSettings" for the keyboard layout is a bit shady, + // but at least for now it is used as "allow popping up windows + // from shell menus"; we can always add a separate sessionMode + // option if need arises. + this._showLayoutItem.visible = Main.sessionMode.allowSettings; + } + + _sourcesChanged() { + for (let i in this._menuItems) + this._menuItems[i].destroy(); + for (let i in this._indicatorLabels) + this._indicatorLabels[i].destroy(); + + this._menuItems = {}; + this._indicatorLabels = {}; + + let menuIndex = 0; + for (let i in this._inputSourceManager.inputSources) { + let is = this._inputSourceManager.inputSources[i]; + + let menuItem = new LayoutMenuItem(is.displayName, is.shortName); + menuItem.connect('activate', () => is.activate(true)); + + let indicatorLabel = new St.Label({ text: is.shortName, + visible: false }); + + this._menuItems[i] = menuItem; + this._indicatorLabels[i] = indicatorLabel; + is.connect('changed', () => { + menuItem.indicator.set_text(is.shortName); + indicatorLabel.set_text(is.shortName); + }); + + this.menu.addMenuItem(menuItem, menuIndex++); + this._container.add_actor(indicatorLabel); + } + } + + _currentSourceChanged(manager, oldSource) { + let nVisibleSources = Object.keys(this._inputSourceManager.inputSources).length; + let newSource = this._inputSourceManager.currentSource; + + if (oldSource) { + this._menuItems[oldSource.index].setOrnament(PopupMenu.Ornament.NONE); + this._indicatorLabels[oldSource.index].hide(); + } + + if (!newSource || (nVisibleSources < 2 && !newSource.properties)) { + // This source index might be invalid if we weren't able + // to build a menu item for it, so we hide ourselves since + // we can't fix it here. *shrug* + + // We also hide if we have only one visible source unless + // it's an IBus source with properties. + this.menu.close(); + this.hide(); + return; + } + + this.show(); + + this._buildPropSection(newSource.properties); + + this._menuItems[newSource.index].setOrnament(PopupMenu.Ornament.DOT); + this._indicatorLabels[newSource.index].show(); + } + + _buildPropSection(properties) { + this._propSeparator.hide(); + this._propSection.actor.hide(); + this._propSection.removeAll(); + + this._buildPropSubMenu(this._propSection, properties); + + if (!this._propSection.isEmpty()) { + this._propSection.actor.show(); + this._propSeparator.show(); + } + } + + _buildPropSubMenu(menu, props) { + if (!props) + return; + + let ibusManager = IBusManager.getIBusManager(); + let radioGroup = []; + let p; + for (let i = 0; (p = props.get(i)) != null; ++i) { + let prop = p; + + if (!prop.get_visible()) + continue; + + if (prop.get_key() == 'InputMode') { + let text; + if (prop.get_symbol) + text = prop.get_symbol().get_text(); + else + text = prop.get_label().get_text(); + + let currentSource = this._inputSourceManager.currentSource; + if (currentSource) { + let indicatorLabel = this._indicatorLabels[currentSource.index]; + if (text && text.length > 0 && text.length < 3) + indicatorLabel.set_text(text); + } + } + + let item; + let type = prop.get_prop_type(); + switch (type) { + case IBus.PropType.MENU: + item = new PopupMenu.PopupSubMenuMenuItem(prop.get_label().get_text()); + this._buildPropSubMenu(item.menu, prop.get_sub_props()); + break; + + case IBus.PropType.RADIO: + item = new PopupMenu.PopupMenuItem(prop.get_label().get_text()); + item.prop = prop; + radioGroup.push(item); + item.radioGroup = radioGroup; + item.setOrnament(prop.get_state() == IBus.PropState.CHECKED + ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE); + item.connect('activate', () => { + if (item.prop.get_state() == IBus.PropState.CHECKED) + return; + + let group = item.radioGroup; + for (let j = 0; j < group.length; ++j) { + if (group[j] == item) { + item.setOrnament(PopupMenu.Ornament.DOT); + item.prop.set_state(IBus.PropState.CHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.CHECKED); + } else { + group[j].setOrnament(PopupMenu.Ornament.NONE); + group[j].prop.set_state(IBus.PropState.UNCHECKED); + ibusManager.activateProperty(group[j].prop.get_key(), + IBus.PropState.UNCHECKED); + } + } + }); + break; + + case IBus.PropType.TOGGLE: + item = new PopupMenu.PopupSwitchMenuItem(prop.get_label().get_text(), prop.get_state() == IBus.PropState.CHECKED); + item.prop = prop; + item.connect('toggled', () => { + if (item.state) { + item.prop.set_state(IBus.PropState.CHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.CHECKED); + } else { + item.prop.set_state(IBus.PropState.UNCHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.UNCHECKED); + } + }); + break; + + case IBus.PropType.NORMAL: + item = new PopupMenu.PopupMenuItem(prop.get_label().get_text()); + item.prop = prop; + item.connect('activate', () => { + ibusManager.activateProperty(item.prop.get_key(), + item.prop.get_state()); + }); + break; + + case IBus.PropType.SEPARATOR: + item = new PopupMenu.PopupSeparatorMenuItem(); + break; + + default: + log('IBus property %s has invalid type %d'.format(prop.get_key(), type)); + continue; + } + + item.setSensitive(prop.get_sensitive()); + menu.addMenuItem(item); + } + } + + _showLayout() { + Main.overview.hide(); + + let source = this._inputSourceManager.currentSource; + let xkbLayout = ''; + let xkbVariant = ''; + + if (source.type == INPUT_SOURCE_TYPE_XKB) { + [, , , xkbLayout, xkbVariant] = KeyboardManager.getXkbInfo().get_layout_info(source.id); + } else if (source.type == INPUT_SOURCE_TYPE_IBUS) { + let engineDesc = IBusManager.getIBusManager().getEngineDesc(source.id); + if (engineDesc) { + xkbLayout = engineDesc.get_layout(); + xkbVariant = engineDesc.get_layout_variant(); + } + } + + if (!xkbLayout || xkbLayout.length == 0) + return; + + let description = xkbLayout; + if (xkbVariant.length > 0) + description = '%s\t%s'.format(description, xkbVariant); + + Util.spawn(['gkbd-keyboard-display', '-l', description]); + } +}); diff --git a/js/ui/status/location.js b/js/ui/status/location.js new file mode 100644 index 0000000..4250ed0 --- /dev/null +++ b/js/ui/status/location.js @@ -0,0 +1,387 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const ModalDialog = imports.ui.modalDialog; +const PermissionStore = imports.misc.permissionStore; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const LOCATION_SCHEMA = 'org.gnome.system.location'; +const MAX_ACCURACY_LEVEL = 'max-accuracy-level'; +const ENABLED = 'enabled'; + +const APP_PERMISSIONS_TABLE = 'gnome'; +const APP_PERMISSIONS_ID = 'geolocation'; + +var GeoclueAccuracyLevel = { + NONE: 0, + COUNTRY: 1, + CITY: 4, + NEIGHBORHOOD: 5, + STREET: 6, + EXACT: 8, +}; + +function accuracyLevelToString(accuracyLevel) { + for (let key in GeoclueAccuracyLevel) { + if (GeoclueAccuracyLevel[key] == accuracyLevel) + return key; + } + + return 'NONE'; +} + +var GeoclueIface = loadInterfaceXML('org.freedesktop.GeoClue2.Manager'); +const GeoclueManager = Gio.DBusProxy.makeProxyWrapper(GeoclueIface); + +var AgentIface = loadInterfaceXML('org.freedesktop.GeoClue2.Agent'); + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._settings = new Gio.Settings({ schema_id: LOCATION_SCHEMA }); + this._settings.connect('changed::%s'.format(ENABLED), + this._onMaxAccuracyLevelChanged.bind(this)); + this._settings.connect('changed::%s'.format(MAX_ACCURACY_LEVEL), + this._onMaxAccuracyLevelChanged.bind(this)); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'find-location-symbolic'; + + this._item = new PopupMenu.PopupSubMenuMenuItem('', true); + this._item.icon.icon_name = 'find-location-symbolic'; + + this._agent = Gio.DBusExportedObject.wrapJSObject(AgentIface, this); + this._agent.export(Gio.DBus.system, '/org/freedesktop/GeoClue2/Agent'); + + this._item.label.text = _("Location Enabled"); + this._onOffAction = this._item.menu.addAction(_("Disable"), this._onOnOffAction.bind(this)); + this._item.menu.addSettingsAction(_('Privacy Settings'), 'gnome-location-panel.desktop'); + + this.menu.addMenuItem(this._item); + + this._watchId = Gio.bus_watch_name(Gio.BusType.SYSTEM, + 'org.freedesktop.GeoClue2', + 0, + this._connectToGeoclue.bind(this), + this._onGeoclueVanished.bind(this)); + Main.sessionMode.connect('updated', this._onSessionUpdated.bind(this)); + this._onSessionUpdated(); + this._onMaxAccuracyLevelChanged(); + this._connectToGeoclue(); + this._connectToPermissionStore(); + } + + get MaxAccuracyLevel() { + return this._getMaxAccuracyLevel(); + } + + AuthorizeAppAsync(params, invocation) { + let [desktopId, reqAccuracyLevel] = params; + + let authorizer = new AppAuthorizer(desktopId, + reqAccuracyLevel, + this._permStoreProxy, + this._getMaxAccuracyLevel()); + + authorizer.authorize(accuracyLevel => { + let ret = accuracyLevel != GeoclueAccuracyLevel.NONE; + invocation.return_value(GLib.Variant.new('(bu)', + [ret, accuracyLevel])); + }); + } + + _syncIndicator() { + if (this._managerProxy == null) { + this._indicator.visible = false; + this._item.visible = false; + return; + } + + this._indicator.visible = this._managerProxy.InUse; + this._item.visible = this._indicator.visible; + this._updateMenuLabels(); + } + + _connectToGeoclue() { + if (this._managerProxy != null || this._connecting) + return false; + + this._connecting = true; + new GeoclueManager(Gio.DBus.system, + 'org.freedesktop.GeoClue2', + '/org/freedesktop/GeoClue2/Manager', + this._onManagerProxyReady.bind(this)); + return true; + } + + _onManagerProxyReady(proxy, error) { + if (error != null) { + log(error.message); + this._connecting = false; + return; + } + + this._managerProxy = proxy; + this._propertiesChangedId = this._managerProxy.connect('g-properties-changed', + this._onGeocluePropsChanged.bind(this)); + + this._syncIndicator(); + + this._managerProxy.AddAgentRemote('gnome-shell', this._onAgentRegistered.bind(this)); + } + + _onAgentRegistered(result, error) { + this._connecting = false; + this._notifyMaxAccuracyLevel(); + + if (error != null) + log(error.message); + } + + _onGeoclueVanished() { + if (this._propertiesChangedId) { + this._managerProxy.disconnect(this._propertiesChangedId); + this._propertiesChangedId = 0; + } + this._managerProxy = null; + + this._syncIndicator(); + } + + _onOnOffAction() { + let enabled = this._settings.get_boolean(ENABLED); + this._settings.set_boolean(ENABLED, !enabled); + } + + _onSessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _updateMenuLabels() { + if (this._settings.get_boolean(ENABLED)) { + this._item.label.text = this._indicator.visible + ? _("Location In Use") + : _("Location Enabled"); + this._onOffAction.label.text = _("Disable"); + } else { + this._item.label.text = _("Location Disabled"); + this._onOffAction.label.text = _("Enable"); + } + } + + _onMaxAccuracyLevelChanged() { + this._updateMenuLabels(); + + // Gotta ensure geoclue is up and we are registered as agent to it + // before we emit the notify for this property change. + if (!this._connectToGeoclue()) + this._notifyMaxAccuracyLevel(); + } + + _getMaxAccuracyLevel() { + if (this._settings.get_boolean(ENABLED)) { + let level = this._settings.get_string(MAX_ACCURACY_LEVEL); + + return GeoclueAccuracyLevel[level.toUpperCase()] || + GeoclueAccuracyLevel.NONE; + } else { + return GeoclueAccuracyLevel.NONE; + } + } + + _notifyMaxAccuracyLevel() { + let variant = new GLib.Variant('u', this._getMaxAccuracyLevel()); + this._agent.emit_property_changed('MaxAccuracyLevel', variant); + } + + _onGeocluePropsChanged(proxy, properties) { + let unpacked = properties.deep_unpack(); + if ("InUse" in unpacked) + this._syncIndicator(); + } + + _connectToPermissionStore() { + this._permStoreProxy = null; + new PermissionStore.PermissionStore(this._onPermStoreProxyReady.bind(this)); + } + + _onPermStoreProxyReady(proxy, error) { + if (error != null) { + log(error.message); + return; + } + + this._permStoreProxy = proxy; + } +}); + +var AppAuthorizer = class { + constructor(desktopId, reqAccuracyLevel, permStoreProxy, maxAccuracyLevel) { + this.desktopId = desktopId; + this.reqAccuracyLevel = reqAccuracyLevel; + this._permStoreProxy = permStoreProxy; + this._maxAccuracyLevel = maxAccuracyLevel; + this._permissions = {}; + + this._accuracyLevel = GeoclueAccuracyLevel.NONE; + } + + authorize(onAuthDone) { + this._onAuthDone = onAuthDone; + + let appSystem = Shell.AppSystem.get_default(); + this._app = appSystem.lookup_app('%s.desktop'.format(this.desktopId)); + if (this._app == null || this._permStoreProxy == null) { + this._completeAuth(); + + return; + } + + this._permStoreProxy.LookupRemote(APP_PERMISSIONS_TABLE, + APP_PERMISSIONS_ID, + this._onPermLookupDone.bind(this)); + } + + _onPermLookupDone(result, error) { + if (error != null) { + if (error.domain == Gio.DBusError) { + // Likely no xdg-app installed, just authorize the app + this._accuracyLevel = this.reqAccuracyLevel; + this._permStoreProxy = null; + this._completeAuth(); + } else { + // Currently xdg-app throws an error if we lookup for + // unknown ID (which would be the case first time this code + // runs) so we continue with user authorization as normal + // and ID is added to the store if user says "yes". + log(error.message); + this._permissions = {}; + this._userAuthorizeApp(); + } + + return; + } + + [this._permissions] = result; + let permission = this._permissions[this.desktopId]; + + if (permission == null) { + this._userAuthorizeApp(); + } else { + let [levelStr] = permission || ['NONE']; + this._accuracyLevel = GeoclueAccuracyLevel[levelStr] || + GeoclueAccuracyLevel.NONE; + this._completeAuth(); + } + } + + _userAuthorizeApp() { + let name = this._app.get_name(); + let appInfo = this._app.get_app_info(); + let reason = appInfo.get_locale_string("X-Geoclue-Reason"); + + this._showAppAuthDialog(name, reason); + } + + _showAppAuthDialog(name, reason) { + this._dialog = new GeolocationDialog(name, + reason, + this.reqAccuracyLevel); + + let responseId = this._dialog.connect('response', (dialog, level) => { + this._dialog.disconnect(responseId); + this._accuracyLevel = level; + this._completeAuth(); + }); + + this._dialog.open(); + } + + _completeAuth() { + if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) { + this._accuracyLevel = Math.clamp(this._accuracyLevel, + 0, this._maxAccuracyLevel); + } + this._saveToPermissionStore(); + + this._onAuthDone(this._accuracyLevel); + } + + _saveToPermissionStore() { + if (this._permStoreProxy == null) + return; + + let levelStr = accuracyLevelToString(this._accuracyLevel); + let dateStr = Math.round(Date.now() / 1000).toString(); + this._permissions[this.desktopId] = [levelStr, dateStr]; + + let data = GLib.Variant.new('av', {}); + + this._permStoreProxy.SetRemote(APP_PERMISSIONS_TABLE, + true, + APP_PERMISSIONS_ID, + this._permissions, + data, + (result, error) => { + if (error != null) + log(error.message); + }); + } +}; + +var GeolocationDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_UINT] } }, +}, class GeolocationDialog extends ModalDialog.ModalDialog { + _init(name, reason, reqAccuracyLevel) { + super._init({ styleClass: 'geolocation-dialog' }); + this.reqAccuracyLevel = reqAccuracyLevel; + + let content = new Dialog.MessageDialogContent({ + title: _('Allow location access'), + /* Translators: %s is an application name */ + description: _('The app %s wants to access your location').format(name), + }); + + let reasonLabel = new St.Label({ + text: reason, + style_class: 'message-dialog-description', + }); + content.add_child(reasonLabel); + + let infoLabel = new St.Label({ + text: _('Location access can be changed at any time from the privacy settings.'), + style_class: 'message-dialog-description', + }); + content.add_child(infoLabel); + + this.contentLayout.add_child(content); + + let button = this.addButton({ label: _("Deny Access"), + action: this._onDenyClicked.bind(this), + key: Clutter.KEY_Escape }); + this.addButton({ label: _("Grant Access"), + action: this._onGrantClicked.bind(this) }); + + this.setInitialKeyFocus(button); + } + + _onGrantClicked() { + this.emit('response', this.reqAccuracyLevel); + this.close(); + } + + _onDenyClicked() { + this.emit('response', GeoclueAccuracyLevel.NONE); + this.close(); + } +}); diff --git a/js/ui/status/network.js b/js/ui/status/network.js new file mode 100644 index 0000000..377f44e --- /dev/null +++ b/js/ui/status/network.js @@ -0,0 +1,2101 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported NMApplet */ +const { Clutter, Gio, GLib, GObject, NM, St } = imports.gi; +const Signals = imports.signals; + +const Animation = imports.ui.animation; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const ModemManager = imports.misc.modemManager; +const Rfkill = imports.ui.status.rfkill; +const Util = imports.misc.util; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +Gio._promisify(Gio.DBusConnection.prototype, 'call', 'call_finish'); +Gio._promisify(NM.Client, 'new_async', 'new_finish'); +Gio._promisify(NM.Client.prototype, + 'check_connectivity_async', 'check_connectivity_finish'); + +const NMConnectionCategory = { + INVALID: 'invalid', + WIRED: 'wired', + WIRELESS: 'wireless', + WWAN: 'wwan', + VPN: 'vpn', +}; + +const NMAccessPointSecurity = { + NONE: 1, + WEP: 2, + WPA_PSK: 3, + WPA2_PSK: 4, + WPA_ENT: 5, + WPA2_ENT: 6, +}; + +var MAX_DEVICE_ITEMS = 4; + +// small optimization, to avoid using [] all the time +const NM80211Mode = NM['80211Mode']; +const NM80211ApFlags = NM['80211ApFlags']; +const NM80211ApSecurityFlags = NM['80211ApSecurityFlags']; + +var PortalHelperResult = { + CANCELLED: 0, + COMPLETED: 1, + RECHECK: 2, +}; + +const PortalHelperIface = loadInterfaceXML('org.gnome.Shell.PortalHelper'); +const PortalHelperProxy = Gio.DBusProxy.makeProxyWrapper(PortalHelperIface); + +function signalToIcon(value) { + if (value > 80) + return 'excellent'; + if (value > 55) + return 'good'; + if (value > 30) + return 'ok'; + if (value > 5) + return 'weak'; + return 'none'; +} + +function ssidToLabel(ssid) { + let label = NM.utils_ssid_to_utf8(ssid.get_data()); + if (!label) + label = _("<unknown>"); + return label; +} + +function ensureActiveConnectionProps(active) { + if (!active._primaryDevice) { + let devices = active.get_devices(); + if (devices.length > 0) { + // This list is guaranteed to have at most one device in it. + let device = devices[0]._delegate; + active._primaryDevice = device; + } + } +} + +function launchSettingsPanel(panel, ...args) { + const param = new GLib.Variant('(sav)', + [panel, args.map(s => new GLib.Variant('s', s))]); + const platformData = { + 'desktop-startup-id': new GLib.Variant('s', + '_TIME%s'.format(global.get_current_time())), + }; + try { + Gio.DBus.session.call( + 'org.gnome.ControlCenter', + '/org/gnome/ControlCenter', + 'org.freedesktop.Application', + 'ActivateAction', + new GLib.Variant('(sava{sv})', + ['launch-panel', [param], platformData]), + null, + Gio.DBusCallFlags.NONE, + -1, + null); + } catch (e) { + log('Failed to launch Settings panel: %s'.format(e.message)); + } +} + +var NMConnectionItem = class { + constructor(section, connection) { + this._section = section; + this._connection = connection; + this._activeConnection = null; + this._activeConnectionChangedId = 0; + + this._buildUI(); + this._sync(); + } + + _buildUI() { + this.labelItem = new PopupMenu.PopupMenuItem(''); + this.labelItem.connect('activate', this._toggle.bind(this)); + + this.radioItem = new PopupMenu.PopupMenuItem(this._connection.get_id(), false); + this.radioItem.connect('activate', this._activate.bind(this)); + } + + destroy() { + this.labelItem.destroy(); + this.radioItem.destroy(); + } + + updateForConnection(connection) { + // connection should always be the same object + // (and object path) as this._connection, but + // this can be false if NetworkManager was restarted + // and picked up connections in a different order + // Just to be safe, we set it here again + + this._connection = connection; + this.radioItem.label.text = connection.get_id(); + this._sync(); + this.emit('name-changed'); + } + + getName() { + return this._connection.get_id(); + } + + isActive() { + if (this._activeConnection == null) + return false; + + return this._activeConnection.state <= NM.ActiveConnectionState.ACTIVATED; + } + + _sync() { + let isActive = this.isActive(); + this.labelItem.label.text = isActive ? _("Turn Off") : this._section.getConnectLabel(); + this.radioItem.setOrnament(isActive ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE); + this.emit('icon-changed'); + } + + _toggle() { + if (this._activeConnection == null) + this._section.activateConnection(this._connection); + else + this._section.deactivateConnection(this._activeConnection); + + this._sync(); + } + + _activate() { + if (this._activeConnection == null) + this._section.activateConnection(this._connection); + + this._sync(); + } + + _connectionStateChanged(_ac, _newstate, _reason) { + this._sync(); + } + + setActiveConnection(activeConnection) { + if (this._activeConnectionChangedId > 0) { + this._activeConnection.disconnect(this._activeConnectionChangedId); + this._activeConnectionChangedId = 0; + } + + this._activeConnection = activeConnection; + + if (this._activeConnection) { + this._activeConnectionChangedId = this._activeConnection.connect('notify::state', + this._connectionStateChanged.bind(this)); + } + + this._sync(); + } +}; +Signals.addSignalMethods(NMConnectionItem.prototype); + +var NMConnectionSection = class NMConnectionSection { + constructor(client) { + if (this.constructor === NMConnectionSection) + throw new TypeError('Cannot instantiate abstract type %s'.format(this.constructor.name)); + + this._client = client; + + this._connectionItems = new Map(); + this._connections = []; + + this._labelSection = new PopupMenu.PopupMenuSection(); + this._radioSection = new PopupMenu.PopupMenuSection(); + + this.item = new PopupMenu.PopupSubMenuMenuItem('', true); + this.item.menu.addMenuItem(this._labelSection); + this.item.menu.addMenuItem(this._radioSection); + + this._notifyConnectivityId = this._client.connect('notify::connectivity', this._iconChanged.bind(this)); + } + + destroy() { + if (this._notifyConnectivityId != 0) { + this._client.disconnect(this._notifyConnectivityId); + this._notifyConnectivityId = 0; + } + + this.item.destroy(); + } + + _iconChanged() { + this._sync(); + this.emit('icon-changed'); + } + + _sync() { + let nItems = this._connectionItems.size; + + this._radioSection.actor.visible = nItems > 1; + this._labelSection.actor.visible = nItems == 1; + + this.item.label.text = this._getStatus(); + this.item.icon.icon_name = this._getMenuIcon(); + } + + _getMenuIcon() { + return this.getIndicatorIcon(); + } + + getConnectLabel() { + return _("Connect"); + } + + _connectionValid(_connection) { + return true; + } + + _connectionSortFunction(one, two) { + return GLib.utf8_collate(one.get_id(), two.get_id()); + } + + _makeConnectionItem(connection) { + return new NMConnectionItem(this, connection); + } + + checkConnection(connection) { + if (!this._connectionValid(connection)) + return; + + // This function is called every time the connection is added or updated. + // In the usual case, we already added this connection and UUID + // didn't change. So we need to check if we already have an item, + // and update it for properties in the connection that changed + // (the only one we care about is the name) + // But it's also possible we didn't know about this connection + // (eg, during coldplug, or because it was updated and suddenly + // it's valid for this device), in which case we add a new item. + + let item = this._connectionItems.get(connection.get_uuid()); + if (item) + this._updateForConnection(item, connection); + else + this._addConnection(connection); + } + + _updateForConnection(item, connection) { + let pos = this._connections.indexOf(connection); + + this._connections.splice(pos, 1); + pos = Util.insertSorted(this._connections, connection, this._connectionSortFunction.bind(this)); + this._labelSection.moveMenuItem(item.labelItem, pos); + this._radioSection.moveMenuItem(item.radioItem, pos); + + item.updateForConnection(connection); + } + + _addConnection(connection) { + let item = this._makeConnectionItem(connection); + if (!item) + return; + + item.connect('icon-changed', () => this._iconChanged()); + item.connect('activation-failed', (o, reason) => { + this.emit('activation-failed', reason); + }); + item.connect('name-changed', this._sync.bind(this)); + + let pos = Util.insertSorted(this._connections, connection, this._connectionSortFunction.bind(this)); + this._labelSection.addMenuItem(item.labelItem, pos); + this._radioSection.addMenuItem(item.radioItem, pos); + this._connectionItems.set(connection.get_uuid(), item); + this._sync(); + } + + removeConnection(connection) { + let uuid = connection.get_uuid(); + let item = this._connectionItems.get(uuid); + if (item == undefined) + return; + + item.destroy(); + this._connectionItems.delete(uuid); + + let pos = this._connections.indexOf(connection); + this._connections.splice(pos, 1); + + this._sync(); + } +}; +Signals.addSignalMethods(NMConnectionSection.prototype); + +var NMConnectionDevice = class NMConnectionDevice extends NMConnectionSection { + constructor(client, device) { + super(client); + + if (this.constructor === NMConnectionDevice) + throw new TypeError('Cannot instantiate abstract type %s'.format(this.constructor.name)); + + this._device = device; + this._description = ''; + + this._autoConnectItem = this.item.menu.addAction(_("Connect"), this._autoConnect.bind(this)); + this._deactivateItem = this._radioSection.addAction(_("Turn Off"), this.deactivateConnection.bind(this)); + + this._stateChangedId = this._device.connect('state-changed', this._deviceStateChanged.bind(this)); + this._activeConnectionChangedId = this._device.connect('notify::active-connection', this._activeConnectionChanged.bind(this)); + } + + _canReachInternet() { + if (this._client.primary_connection != this._device.active_connection) + return true; + + return this._client.connectivity == NM.ConnectivityState.FULL; + } + + _autoConnect() { + let connection = new NM.SimpleConnection(); + this._client.add_and_activate_connection_async(connection, this._device, null, null, null); + } + + destroy() { + if (this._stateChangedId) { + GObject.signal_handler_disconnect(this._device, this._stateChangedId); + this._stateChangedId = 0; + } + if (this._activeConnectionChangedId) { + GObject.signal_handler_disconnect(this._device, this._activeConnectionChangedId); + this._activeConnectionChangedId = 0; + } + + super.destroy(); + } + + _activeConnectionChanged() { + if (this._activeConnection) { + let item = this._connectionItems.get(this._activeConnection.connection.get_uuid()); + item.setActiveConnection(null); + this._activeConnection = null; + } + + this._sync(); + } + + _deviceStateChanged(device, newstate, oldstate, reason) { + if (newstate == oldstate) { + log('device emitted state-changed without actually changing state'); + return; + } + + /* Emit a notification if activation fails, but don't do it + if the reason is no secrets, as that indicates the user + cancelled the agent dialog */ + if (newstate == NM.DeviceState.FAILED && + reason != NM.DeviceStateReason.NO_SECRETS) + this.emit('activation-failed', reason); + + this._sync(); + } + + _connectionValid(connection) { + return this._device.connection_valid(connection); + } + + activateConnection(connection) { + this._client.activate_connection_async(connection, this._device, null, null, null); + } + + deactivateConnection(_activeConnection) { + this._device.disconnect(null); + } + + setDeviceDescription(desc) { + this._description = desc; + this._sync(); + } + + _getDescription() { + return this._description; + } + + _sync() { + let nItems = this._connectionItems.size; + this._autoConnectItem.visible = nItems == 0; + this._deactivateItem.visible = this._device.state > NM.DeviceState.DISCONNECTED; + + if (this._activeConnection == null) { + let activeConnection = this._device.active_connection; + if (activeConnection && activeConnection.connection) { + let item = this._connectionItems.get(activeConnection.connection.get_uuid()); + if (item) { + this._activeConnection = activeConnection; + ensureActiveConnectionProps(this._activeConnection); + item.setActiveConnection(this._activeConnection); + } + } + } + + super._sync(); + } + + _getStatus() { + if (!this._device) + return ''; + + switch (this._device.state) { + case NM.DeviceState.DISCONNECTED: + /* Translators: %s is a network identifier */ + return _("%s Off").format(this._getDescription()); + case NM.DeviceState.ACTIVATED: + /* Translators: %s is a network identifier */ + return _("%s Connected").format(this._getDescription()); + case NM.DeviceState.UNMANAGED: + /* Translators: this is for network devices that are physically present but are not + under NetworkManager's control (and thus cannot be used in the menu); + %s is a network identifier */ + return _("%s Unmanaged").format(this._getDescription()); + case NM.DeviceState.DEACTIVATING: + /* Translators: %s is a network identifier */ + return _("%s Disconnecting").format(this._getDescription()); + case NM.DeviceState.PREPARE: + case NM.DeviceState.CONFIG: + case NM.DeviceState.IP_CONFIG: + case NM.DeviceState.IP_CHECK: + case NM.DeviceState.SECONDARIES: + /* Translators: %s is a network identifier */ + return _("%s Connecting").format(this._getDescription()); + case NM.DeviceState.NEED_AUTH: + /* Translators: this is for network connections that require some kind of key or password; %s is a network identifier */ + return _("%s Requires Authentication").format(this._getDescription()); + case NM.DeviceState.UNAVAILABLE: + // This state is actually a compound of various states (generically unavailable, + // firmware missing), that are exposed by different properties (whose state may + // or may not updated when we receive state-changed). + if (this._device.firmware_missing) { + /* Translators: this is for devices that require some kind of firmware or kernel + module, which is missing; %s is a network identifier */ + return _("Firmware Missing For %s").format(this._getDescription()); + } + /* Translators: this is for a network device that cannot be activated (for example it + is disabled by rfkill, or it has no coverage; %s is a network identifier */ + return _("%s Unavailable").format(this._getDescription()); + case NM.DeviceState.FAILED: + /* Translators: %s is a network identifier */ + return _("%s Connection Failed").format(this._getDescription()); + default: + log('Device state invalid, is %d'.format(this._device.state)); + return 'invalid'; + } + } +}; + +var NMDeviceWired = class extends NMConnectionDevice { + constructor(client, device) { + super(client, device); + + this.item.menu.addSettingsAction(_("Wired Settings"), 'gnome-network-panel.desktop'); + } + + get category() { + return NMConnectionCategory.WIRED; + } + + _hasCarrier() { + if (this._device instanceof NM.DeviceEthernet) + return this._device.carrier; + else + return true; + } + + _sync() { + this.item.visible = this._hasCarrier(); + super._sync(); + } + + getIndicatorIcon() { + if (this._device.active_connection) { + let state = this._device.active_connection.state; + + if (state == NM.ActiveConnectionState.ACTIVATING) { + return 'network-wired-acquiring-symbolic'; + } else if (state == NM.ActiveConnectionState.ACTIVATED) { + if (this._canReachInternet()) + return 'network-wired-symbolic'; + else + return 'network-wired-no-route-symbolic'; + } else { + return 'network-wired-disconnected-symbolic'; + } + } else { + return 'network-wired-disconnected-symbolic'; + } + } +}; + +var NMDeviceModem = class extends NMConnectionDevice { + constructor(client, device) { + super(client, device); + + this.item.menu.addSettingsAction(_("Mobile Broadband Settings"), 'gnome-network-panel.desktop'); + + this._mobileDevice = null; + + let capabilities = device.current_capabilities; + if (device.udi.indexOf('/org/freedesktop/ModemManager1/Modem') == 0) + this._mobileDevice = new ModemManager.BroadbandModem(device.udi, capabilities); + else if (capabilities & NM.DeviceModemCapabilities.GSM_UMTS) + this._mobileDevice = new ModemManager.ModemGsm(device.udi); + else if (capabilities & NM.DeviceModemCapabilities.CDMA_EVDO) + this._mobileDevice = new ModemManager.ModemCdma(device.udi); + else if (capabilities & NM.DeviceModemCapabilities.LTE) + this._mobileDevice = new ModemManager.ModemGsm(device.udi); + + if (this._mobileDevice) { + this._operatorNameId = this._mobileDevice.connect('notify::operator-name', this._sync.bind(this)); + this._signalQualityId = this._mobileDevice.connect('notify::signal-quality', () => { + this._iconChanged(); + }); + } + } + + get category() { + return NMConnectionCategory.WWAN; + } + + _autoConnect() { + launchSettingsPanel('network', 'connect-3g', this._device.get_path()); + } + + destroy() { + if (this._operatorNameId) { + this._mobileDevice.disconnect(this._operatorNameId); + this._operatorNameId = 0; + } + if (this._signalQualityId) { + this._mobileDevice.disconnect(this._signalQualityId); + this._signalQualityId = 0; + } + + super.destroy(); + } + + _getStatus() { + if (!this._client.wwan_hardware_enabled) + /* Translators: %s is a network identifier */ + return _("%s Hardware Disabled").format(this._getDescription()); + else if (!this._client.wwan_enabled) + /* Translators: this is for a network device that cannot be activated + because it's disabled by rfkill (airplane mode); %s is a network identifier */ + return _("%s Disabled").format(this._getDescription()); + else if (this._device.state == NM.DeviceState.ACTIVATED && + this._mobileDevice && this._mobileDevice.operator_name) + return this._mobileDevice.operator_name; + else + return super._getStatus(); + } + + getIndicatorIcon() { + if (this._device.active_connection) { + if (this._device.active_connection.state == NM.ActiveConnectionState.ACTIVATING) + return 'network-cellular-acquiring-symbolic'; + + return this._getSignalIcon(); + } else { + return 'network-cellular-signal-none-symbolic'; + } + } + + _getSignalIcon() { + return 'network-cellular-signal-%s-symbolic'.format( + signalToIcon(this._mobileDevice.signal_quality)); + } +}; + +var NMDeviceBluetooth = class extends NMConnectionDevice { + constructor(client, device) { + super(client, device); + + this.item.menu.addSettingsAction(_("Bluetooth Settings"), 'gnome-network-panel.desktop'); + } + + get category() { + return NMConnectionCategory.WWAN; + } + + _getDescription() { + return this._device.name; + } + + getConnectLabel() { + return _("Connect to Internet"); + } + + getIndicatorIcon() { + if (this._device.active_connection) { + let state = this._device.active_connection.state; + if (state == NM.ActiveConnectionState.ACTIVATING) + return 'network-cellular-acquiring-symbolic'; + else if (state == NM.ActiveConnectionState.ACTIVATED) + return 'network-cellular-connected-symbolic'; + else + return 'network-cellular-signal-none-symbolic'; + } else { + return 'network-cellular-signal-none-symbolic'; + } + } +}; + +var NMWirelessDialogItem = GObject.registerClass({ + Signals: { + 'selected': {}, + }, +}, class NMWirelessDialogItem extends St.BoxLayout { + _init(network) { + this._network = network; + this._ap = network.accessPoints[0]; + + super._init({ style_class: 'nm-dialog-item', + can_focus: true, + reactive: true }); + + let action = new Clutter.ClickAction(); + action.connect('clicked', () => this.grab_key_focus()); + this.add_action(action); + + let title = ssidToLabel(this._ap.get_ssid()); + this._label = new St.Label({ + text: title, + x_expand: true, + }); + + this.label_actor = this._label; + this.add_child(this._label); + + this._selectedIcon = new St.Icon({ style_class: 'nm-dialog-icon', + icon_name: 'object-select-symbolic' }); + this.add(this._selectedIcon); + + this._icons = new St.BoxLayout({ + style_class: 'nm-dialog-icons', + x_align: Clutter.ActorAlign.END, + }); + this.add_child(this._icons); + + this._secureIcon = new St.Icon({ style_class: 'nm-dialog-icon' }); + if (this._ap._secType != NMAccessPointSecurity.NONE) + this._secureIcon.icon_name = 'network-wireless-encrypted-symbolic'; + this._icons.add_actor(this._secureIcon); + + this._signalIcon = new St.Icon({ style_class: 'nm-dialog-icon' }); + this._icons.add_actor(this._signalIcon); + + this._sync(); + } + + vfunc_key_focus_in() { + this.emit('selected'); + } + + _sync() { + this._signalIcon.icon_name = this._getSignalIcon(); + } + + updateBestAP(ap) { + this._ap = ap; + this._sync(); + } + + setActive(isActive) { + this._selectedIcon.opacity = isActive ? 255 : 0; + } + + _getSignalIcon() { + if (this._ap.mode == NM80211Mode.ADHOC) { + return 'network-workgroup-symbolic'; + } else { + return 'network-wireless-signal-%s-symbolic'.format( + signalToIcon(this._ap.strength)); + } + } +}); + +var NMWirelessDialog = GObject.registerClass( +class NMWirelessDialog extends ModalDialog.ModalDialog { + _init(client, device) { + super._init({ styleClass: 'nm-dialog' }); + + this._client = client; + this._device = device; + + this._wirelessEnabledChangedId = this._client.connect('notify::wireless-enabled', + this._syncView.bind(this)); + + this._rfkill = Rfkill.getRfkillManager(); + this._airplaneModeChangedId = this._rfkill.connect('airplane-mode-changed', + this._syncView.bind(this)); + + this._networks = []; + this._buildLayout(); + + let connections = client.get_connections(); + this._connections = connections.filter( + connection => device.connection_valid(connection)); + + this._apAddedId = device.connect('access-point-added', this._accessPointAdded.bind(this)); + this._apRemovedId = device.connect('access-point-removed', this._accessPointRemoved.bind(this)); + this._activeApChangedId = device.connect('notify::active-access-point', this._activeApChanged.bind(this)); + + // accessPointAdded will also create dialog items + let accessPoints = device.get_access_points() || []; + accessPoints.forEach(ap => { + this._accessPointAdded(this._device, ap); + }); + + this._selectedNetwork = null; + this._activeApChanged(); + this._updateSensitivity(); + this._syncView(); + + this._scanTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 15, this._onScanTimeout.bind(this)); + GLib.Source.set_name_by_id(this._scanTimeoutId, '[gnome-shell] this._onScanTimeout'); + this._onScanTimeout(); + + let id = Main.sessionMode.connect('updated', () => { + if (Main.sessionMode.allowSettings) + return; + + Main.sessionMode.disconnect(id); + this.close(); + }); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._apAddedId) { + GObject.Object.prototype.disconnect.call(this._device, this._apAddedId); + this._apAddedId = 0; + } + if (this._apRemovedId) { + GObject.Object.prototype.disconnect.call(this._device, this._apRemovedId); + this._apRemovedId = 0; + } + if (this._activeApChangedId) { + GObject.Object.prototype.disconnect.call(this._device, this._activeApChangedId); + this._activeApChangedId = 0; + } + if (this._wirelessEnabledChangedId) { + this._client.disconnect(this._wirelessEnabledChangedId); + this._wirelessEnabledChangedId = 0; + } + if (this._airplaneModeChangedId) { + this._rfkill.disconnect(this._airplaneModeChangedId); + this._airplaneModeChangedId = 0; + } + + if (this._scanTimeoutId) { + GLib.source_remove(this._scanTimeoutId); + this._scanTimeoutId = 0; + } + } + + _onScanTimeout() { + this._device.request_scan_async(null, null); + return GLib.SOURCE_CONTINUE; + } + + _activeApChanged() { + if (this._activeNetwork) + this._activeNetwork.item.setActive(false); + + this._activeNetwork = null; + if (this._device.active_access_point) { + let idx = this._findNetwork(this._device.active_access_point); + if (idx >= 0) + this._activeNetwork = this._networks[idx]; + } + + if (this._activeNetwork) + this._activeNetwork.item.setActive(true); + this._updateSensitivity(); + } + + _updateSensitivity() { + let connectSensitive = this._client.wireless_enabled && this._selectedNetwork && (this._selectedNetwork != this._activeNetwork); + this._connectButton.reactive = connectSensitive; + this._connectButton.can_focus = connectSensitive; + } + + _syncView() { + if (this._rfkill.airplaneMode) { + this._airplaneBox.show(); + + this._airplaneIcon.icon_name = 'airplane-mode-symbolic'; + this._airplaneHeadline.text = _("Airplane Mode is On"); + this._airplaneText.text = _("Wi-Fi is disabled when airplane mode is on."); + this._airplaneButton.label = _("Turn Off Airplane Mode"); + + this._airplaneButton.visible = !this._rfkill.hwAirplaneMode; + this._airplaneInactive.visible = this._rfkill.hwAirplaneMode; + this._noNetworksBox.hide(); + } else if (!this._client.wireless_enabled) { + this._airplaneBox.show(); + + this._airplaneIcon.icon_name = 'dialog-information-symbolic'; + this._airplaneHeadline.text = _("Wi-Fi is Off"); + this._airplaneText.text = _("Wi-Fi needs to be turned on in order to connect to a network."); + this._airplaneButton.label = _("Turn On Wi-Fi"); + + this._airplaneButton.show(); + this._airplaneInactive.hide(); + this._noNetworksBox.hide(); + } else { + this._airplaneBox.hide(); + + this._noNetworksBox.visible = this._networks.length == 0; + } + + if (this._noNetworksBox.visible) + this._noNetworksSpinner.play(); + else + this._noNetworksSpinner.stop(); + } + + _buildLayout() { + let headline = new St.BoxLayout({ style_class: 'nm-dialog-header-hbox' }); + + let icon = new St.Icon({ style_class: 'nm-dialog-header-icon', + icon_name: 'network-wireless-signal-excellent-symbolic' }); + + let titleBox = new St.BoxLayout({ vertical: true }); + let title = new St.Label({ style_class: 'nm-dialog-header', + text: _("Wi-Fi Networks") }); + let subtitle = new St.Label({ style_class: 'nm-dialog-subheader', + text: _("Select a network") }); + titleBox.add(title); + titleBox.add(subtitle); + + headline.add(icon); + headline.add(titleBox); + + this.contentLayout.style_class = 'nm-dialog-content'; + this.contentLayout.add(headline); + + this._stack = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + y_expand: true, + }); + + this._itemBox = new St.BoxLayout({ vertical: true }); + this._scrollView = new St.ScrollView({ style_class: 'nm-dialog-scroll-view' }); + this._scrollView.set_x_expand(true); + this._scrollView.set_y_expand(true); + this._scrollView.set_policy(St.PolicyType.NEVER, + St.PolicyType.AUTOMATIC); + this._scrollView.add_actor(this._itemBox); + this._stack.add_child(this._scrollView); + + this._noNetworksBox = new St.BoxLayout({ vertical: true, + style_class: 'no-networks-box', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER }); + + this._noNetworksSpinner = new Animation.Spinner(16); + this._noNetworksBox.add_actor(this._noNetworksSpinner); + this._noNetworksBox.add_actor(new St.Label({ style_class: 'no-networks-label', + text: _("No Networks") })); + this._stack.add_child(this._noNetworksBox); + + this._airplaneBox = new St.BoxLayout({ vertical: true, + style_class: 'nm-dialog-airplane-box', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER }); + this._airplaneIcon = new St.Icon({ icon_size: 48 }); + this._airplaneHeadline = new St.Label({ style_class: 'nm-dialog-airplane-headline headline' }); + this._airplaneText = new St.Label({ style_class: 'nm-dialog-airplane-text' }); + + let airplaneSubStack = new St.Widget({ layout_manager: new Clutter.BinLayout() }); + this._airplaneButton = new St.Button({ style_class: 'modal-dialog-button button' }); + this._airplaneButton.connect('clicked', () => { + if (this._rfkill.airplaneMode) + this._rfkill.airplaneMode = false; + else + this._client.wireless_enabled = true; + }); + airplaneSubStack.add_actor(this._airplaneButton); + this._airplaneInactive = new St.Label({ style_class: 'nm-dialog-airplane-text', + text: _("Use hardware switch to turn off") }); + airplaneSubStack.add_actor(this._airplaneInactive); + + this._airplaneBox.add_child(this._airplaneIcon); + this._airplaneBox.add_child(this._airplaneHeadline); + this._airplaneBox.add_child(this._airplaneText); + this._airplaneBox.add_child(airplaneSubStack); + this._stack.add_child(this._airplaneBox); + + this.contentLayout.add_child(this._stack); + + this._disconnectButton = this.addButton({ action: () => this.close(), + label: _("Cancel"), + key: Clutter.KEY_Escape }); + this._connectButton = this.addButton({ action: this._connect.bind(this), + label: _("Connect"), + key: Clutter.KEY_Return }); + } + + _connect() { + let network = this._selectedNetwork; + if (network.connections.length > 0) { + let connection = network.connections[0]; + this._client.activate_connection_async(connection, this._device, null, null, null); + } else { + let accessPoints = network.accessPoints; + if ((accessPoints[0]._secType == NMAccessPointSecurity.WPA2_ENT) || + (accessPoints[0]._secType == NMAccessPointSecurity.WPA_ENT)) { + // 802.1x-enabled APs require further configuration, so they're + // handled in gnome-control-center + launchSettingsPanel('wifi', 'connect-8021x-wifi', + this._device.get_path(), accessPoints[0].get_path()); + } else { + let connection = new NM.SimpleConnection(); + this._client.add_and_activate_connection_async(connection, this._device, accessPoints[0].get_path(), null, null); + } + } + + this.close(); + } + + _notifySsidCb(accessPoint) { + if (accessPoint.get_ssid() != null) { + accessPoint.disconnect(accessPoint._notifySsidId); + accessPoint._notifySsidId = 0; + this._accessPointAdded(this._device, accessPoint); + } + } + + _getApSecurityType(accessPoint) { + if (accessPoint._secType) + return accessPoint._secType; + + let flags = accessPoint.flags; + let wpaFlags = accessPoint.wpa_flags; + let rsnFlags = accessPoint.rsn_flags; + let type; + if (rsnFlags != NM80211ApSecurityFlags.NONE) { + /* RSN check first so that WPA+WPA2 APs are treated as RSN/WPA2 */ + if (rsnFlags & NM80211ApSecurityFlags.KEY_MGMT_802_1X) + type = NMAccessPointSecurity.WPA2_ENT; + else if (rsnFlags & NM80211ApSecurityFlags.KEY_MGMT_PSK) + type = NMAccessPointSecurity.WPA2_PSK; + } else if (wpaFlags != NM80211ApSecurityFlags.NONE) { + if (wpaFlags & NM80211ApSecurityFlags.KEY_MGMT_802_1X) + type = NMAccessPointSecurity.WPA_ENT; + else if (wpaFlags & NM80211ApSecurityFlags.KEY_MGMT_PSK) + type = NMAccessPointSecurity.WPA_PSK; + } else { + // eslint-disable-next-line no-lonely-if + if (flags & NM80211ApFlags.PRIVACY) + type = NMAccessPointSecurity.WEP; + else + type = NMAccessPointSecurity.NONE; + } + + // cache the found value to avoid checking flags all the time + accessPoint._secType = type; + return type; + } + + _networkSortFunction(one, two) { + let oneHasConnection = one.connections.length != 0; + let twoHasConnection = two.connections.length != 0; + + // place known connections first + // (-1 = good order, 1 = wrong order) + if (oneHasConnection && !twoHasConnection) + return -1; + else if (!oneHasConnection && twoHasConnection) + return 1; + + let oneAp = one.accessPoints[0] || null; + let twoAp = two.accessPoints[0] || null; + + if (oneAp != null && twoAp == null) + return -1; + else if (oneAp == null && twoAp != null) + return 1; + + let oneStrength = oneAp.strength; + let twoStrength = twoAp.strength; + + // place stronger connections first + if (oneStrength != twoStrength) + return oneStrength < twoStrength ? 1 : -1; + + let oneHasSecurity = one.security != NMAccessPointSecurity.NONE; + let twoHasSecurity = two.security != NMAccessPointSecurity.NONE; + + // place secure connections first + // (we treat WEP/WPA/WPA2 the same as there is no way to + // take them apart from the UI) + if (oneHasSecurity && !twoHasSecurity) + return -1; + else if (!oneHasSecurity && twoHasSecurity) + return 1; + + // sort alphabetically + return GLib.utf8_collate(one.ssidText, two.ssidText); + } + + _networkCompare(network, accessPoint) { + if (!network.ssid.equal(accessPoint.get_ssid())) + return false; + if (network.mode != accessPoint.mode) + return false; + if (network.security != this._getApSecurityType(accessPoint)) + return false; + + return true; + } + + _findExistingNetwork(accessPoint) { + for (let i = 0; i < this._networks.length; i++) { + let network = this._networks[i]; + for (let j = 0; j < network.accessPoints.length; j++) { + if (network.accessPoints[j] == accessPoint) + return { network: i, ap: j }; + } + } + + return null; + } + + _findNetwork(accessPoint) { + if (accessPoint.get_ssid() == null) + return -1; + + for (let i = 0; i < this._networks.length; i++) { + if (this._networkCompare(this._networks[i], accessPoint)) + return i; + } + return -1; + } + + _checkConnections(network, accessPoint) { + this._connections.forEach(connection => { + if (accessPoint.connection_valid(connection) && + !network.connections.includes(connection)) + network.connections.push(connection); + }); + } + + _accessPointAdded(device, accessPoint) { + if (accessPoint.get_ssid() == null) { + // This access point is not visible yet + // Wait for it to get a ssid + accessPoint._notifySsidId = accessPoint.connect('notify::ssid', this._notifySsidCb.bind(this)); + return; + } + + let pos = this._findNetwork(accessPoint); + let network; + + if (pos != -1) { + network = this._networks[pos]; + if (network.accessPoints.includes(accessPoint)) { + log('Access point was already seen, not adding again'); + return; + } + + Util.insertSorted(network.accessPoints, accessPoint, (one, two) => { + return two.strength - one.strength; + }); + network.item.updateBestAP(network.accessPoints[0]); + this._checkConnections(network, accessPoint); + + this._resortItems(); + } else { + network = { + ssid: accessPoint.get_ssid(), + mode: accessPoint.mode, + security: this._getApSecurityType(accessPoint), + connections: [], + item: null, + accessPoints: [accessPoint], + }; + network.ssidText = ssidToLabel(network.ssid); + this._checkConnections(network, accessPoint); + + let newPos = Util.insertSorted(this._networks, network, this._networkSortFunction); + this._createNetworkItem(network); + this._itemBox.insert_child_at_index(network.item, newPos); + } + + this._syncView(); + } + + _accessPointRemoved(device, accessPoint) { + let res = this._findExistingNetwork(accessPoint); + + if (res == null) { + log('Removing an access point that was never added'); + return; + } + + let network = this._networks[res.network]; + network.accessPoints.splice(res.ap, 1); + + if (network.accessPoints.length == 0) { + network.item.destroy(); + this._networks.splice(res.network, 1); + } else { + network.item.updateBestAP(network.accessPoints[0]); + this._resortItems(); + } + + this._syncView(); + } + + _resortItems() { + let adjustment = this._scrollView.vscroll.adjustment; + let scrollValue = adjustment.value; + + this._itemBox.remove_all_children(); + this._networks.forEach(network => { + this._itemBox.add_child(network.item); + }); + + adjustment.value = scrollValue; + } + + _selectNetwork(network) { + if (this._selectedNetwork) + this._selectedNetwork.item.remove_style_pseudo_class('selected'); + + this._selectedNetwork = network; + this._updateSensitivity(); + + if (this._selectedNetwork) + this._selectedNetwork.item.add_style_pseudo_class('selected'); + } + + _createNetworkItem(network) { + network.item = new NMWirelessDialogItem(network); + network.item.setActive(network == this._selectedNetwork); + network.item.connect('selected', () => { + Util.ensureActorVisibleInScrollView(this._scrollView, network.item); + this._selectNetwork(network); + }); + network.item.connect('destroy', () => { + let keyFocus = global.stage.key_focus; + if (keyFocus && keyFocus.contains(network.item)) + this._itemBox.grab_key_focus(); + }); + } +}); + +var NMDeviceWireless = class { + constructor(client, device) { + this._client = client; + this._device = device; + + this._description = ''; + + this.item = new PopupMenu.PopupSubMenuMenuItem('', true); + this.item.menu.addAction(_("Select Network"), this._showDialog.bind(this)); + + this._toggleItem = new PopupMenu.PopupMenuItem(''); + this._toggleItem.connect('activate', this._toggleWifi.bind(this)); + this.item.menu.addMenuItem(this._toggleItem); + + this.item.menu.addSettingsAction(_("Wi-Fi Settings"), 'gnome-wifi-panel.desktop'); + + this._wirelessEnabledChangedId = this._client.connect('notify::wireless-enabled', this._sync.bind(this)); + this._wirelessHwEnabledChangedId = this._client.connect('notify::wireless-hardware-enabled', this._sync.bind(this)); + this._activeApChangedId = this._device.connect('notify::active-access-point', this._activeApChanged.bind(this)); + this._stateChangedId = this._device.connect('state-changed', this._deviceStateChanged.bind(this)); + this._notifyConnectivityId = this._client.connect('notify::connectivity', this._iconChanged.bind(this)); + + this._sync(); + } + + get category() { + return NMConnectionCategory.WIRELESS; + } + + _iconChanged() { + this._sync(); + this.emit('icon-changed'); + } + + destroy() { + if (this._activeApChangedId) { + GObject.signal_handler_disconnect(this._device, this._activeApChangedId); + this._activeApChangedId = 0; + } + if (this._stateChangedId) { + GObject.signal_handler_disconnect(this._device, this._stateChangedId); + this._stateChangedId = 0; + } + if (this._strengthChangedId > 0) { + this._activeAccessPoint.disconnect(this._strengthChangedId); + this._strengthChangedId = 0; + } + if (this._wirelessEnabledChangedId) { + this._client.disconnect(this._wirelessEnabledChangedId); + this._wirelessEnabledChangedId = 0; + } + if (this._wirelessHwEnabledChangedId) { + this._client.disconnect(this._wirelessHwEnabledChangedId); + this._wirelessHwEnabledChangedId = 0; + } + if (this._dialog) { + this._dialog.destroy(); + this._dialog = null; + } + if (this._notifyConnectivityId) { + this._client.disconnect(this._notifyConnectivityId); + this._notifyConnectivityId = 0; + } + + this.item.destroy(); + } + + _deviceStateChanged(device, newstate, oldstate, reason) { + if (newstate == oldstate) { + log('device emitted state-changed without actually changing state'); + return; + } + + /* Emit a notification if activation fails, but don't do it + if the reason is no secrets, as that indicates the user + cancelled the agent dialog */ + if (newstate == NM.DeviceState.FAILED && + reason != NM.DeviceStateReason.NO_SECRETS) + this.emit('activation-failed', reason); + + this._sync(); + } + + _toggleWifi() { + this._client.wireless_enabled = !this._client.wireless_enabled; + } + + _showDialog() { + this._dialog = new NMWirelessDialog(this._client, this._device); + this._dialog.connect('closed', this._dialogClosed.bind(this)); + this._dialog.open(); + } + + _dialogClosed() { + this._dialog = null; + } + + _strengthChanged() { + this._iconChanged(); + } + + _activeApChanged() { + if (this._activeAccessPoint) { + this._activeAccessPoint.disconnect(this._strengthChangedId); + this._strengthChangedId = 0; + } + + this._activeAccessPoint = this._device.active_access_point; + + if (this._activeAccessPoint) { + this._strengthChangedId = this._activeAccessPoint.connect('notify::strength', + this._strengthChanged.bind(this)); + } + + this._sync(); + } + + _sync() { + this._toggleItem.label.text = this._client.wireless_enabled ? _("Turn Off") : _("Turn On"); + this._toggleItem.visible = this._client.wireless_hardware_enabled; + + this.item.icon.icon_name = this._getMenuIcon(); + this.item.label.text = this._getStatus(); + } + + setDeviceDescription(desc) { + this._description = desc; + this._sync(); + } + + _getStatus() { + let ap = this._device.active_access_point; + + if (this._isHotSpotMaster()) + /* Translators: %s is a network identifier */ + return _("%s Hotspot Active").format(this._description); + else if (this._device.state >= NM.DeviceState.PREPARE && + this._device.state < NM.DeviceState.ACTIVATED) + /* Translators: %s is a network identifier */ + return _("%s Connecting").format(this._description); + else if (ap) + return ssidToLabel(ap.get_ssid()); + else if (!this._client.wireless_hardware_enabled) + /* Translators: %s is a network identifier */ + return _("%s Hardware Disabled").format(this._description); + else if (!this._client.wireless_enabled) + /* Translators: %s is a network identifier */ + return _("%s Off").format(this._description); + else if (this._device.state == NM.DeviceState.DISCONNECTED) + /* Translators: %s is a network identifier */ + return _("%s Not Connected").format(this._description); + else + return ''; + } + + _getMenuIcon() { + if (this._device.active_connection) + return this.getIndicatorIcon(); + else + return 'network-wireless-signal-none-symbolic'; + } + + _canReachInternet() { + if (this._client.primary_connection != this._device.active_connection) + return true; + + return this._client.connectivity == NM.ConnectivityState.FULL; + } + + _isHotSpotMaster() { + if (!this._device.active_connection) + return false; + + let connection = this._device.active_connection.connection; + if (!connection) + return false; + + let ip4config = connection.get_setting_ip4_config(); + if (!ip4config) + return false; + + return ip4config.get_method() == NM.SETTING_IP4_CONFIG_METHOD_SHARED; + } + + getIndicatorIcon() { + if (this._device.state < NM.DeviceState.PREPARE) + return 'network-wireless-disconnected-symbolic'; + if (this._device.state < NM.DeviceState.ACTIVATED) + return 'network-wireless-acquiring-symbolic'; + + if (this._isHotSpotMaster()) + return 'network-wireless-hotspot-symbolic'; + + let ap = this._device.active_access_point; + if (!ap) { + if (this._device.mode != NM80211Mode.ADHOC) + log('An active wireless connection, in infrastructure mode, involves no access point?'); + + if (this._canReachInternet()) + return 'network-wireless-connected-symbolic'; + else + return 'network-wireless-no-route-symbolic'; + } + + if (this._canReachInternet()) + return 'network-wireless-signal-%s-symbolic'.format(signalToIcon(ap.strength)); + else + return 'network-wireless-no-route-symbolic'; + } +}; +Signals.addSignalMethods(NMDeviceWireless.prototype); + +var NMVpnConnectionItem = class extends NMConnectionItem { + isActive() { + if (this._activeConnection == null) + return false; + + return this._activeConnection.vpn_state != NM.VpnConnectionState.DISCONNECTED; + } + + _buildUI() { + this.labelItem = new PopupMenu.PopupMenuItem(''); + this.labelItem.connect('activate', this._toggle.bind(this)); + + this.radioItem = new PopupMenu.PopupSwitchMenuItem(this._connection.get_id(), false); + this.radioItem.connect('toggled', this._toggle.bind(this)); + } + + _sync() { + let isActive = this.isActive(); + this.labelItem.label.text = isActive ? _("Turn Off") : this._section.getConnectLabel(); + this.radioItem.setToggleState(isActive); + this.radioItem.setStatus(this._getStatus()); + this.emit('icon-changed'); + } + + _getStatus() { + if (this._activeConnection == null) + return null; + + switch (this._activeConnection.vpn_state) { + case NM.VpnConnectionState.DISCONNECTED: + case NM.VpnConnectionState.ACTIVATED: + return null; + case NM.VpnConnectionState.PREPARE: + case NM.VpnConnectionState.CONNECT: + case NM.VpnConnectionState.IP_CONFIG_GET: + return _("connecting…"); + case NM.VpnConnectionState.NEED_AUTH: + /* Translators: this is for network connections that require some kind of key or password */ + return _("authentication required"); + case NM.VpnConnectionState.FAILED: + return _("connection failed"); + default: + return 'invalid'; + } + } + + _connectionStateChanged(ac, newstate, reason) { + if (newstate == NM.VpnConnectionState.FAILED && + reason != NM.VpnConnectionStateReason.NO_SECRETS) { + // FIXME: if we ever want to show something based on reason, + // we need to convert from NM.VpnConnectionStateReason + // to NM.DeviceStateReason + this.emit('activation-failed', reason); + } + + this.emit('icon-changed'); + super._connectionStateChanged(); + } + + setActiveConnection(activeConnection) { + if (this._activeConnectionChangedId > 0) { + this._activeConnection.disconnect(this._activeConnectionChangedId); + this._activeConnectionChangedId = 0; + } + + this._activeConnection = activeConnection; + + if (this._activeConnection) { + this._activeConnectionChangedId = this._activeConnection.connect('vpn-state-changed', + this._connectionStateChanged.bind(this)); + } + + this._sync(); + } + + getIndicatorIcon() { + if (this._activeConnection) { + if (this._activeConnection.vpn_state < NM.VpnConnectionState.ACTIVATED) + return 'network-vpn-acquiring-symbolic'; + else + return 'network-vpn-symbolic'; + } else { + return ''; + } + } +}; + +var NMVpnSection = class extends NMConnectionSection { + constructor(client) { + super(client); + + this.item.menu.addSettingsAction(_("VPN Settings"), 'gnome-network-panel.desktop'); + + this._sync(); + } + + _sync() { + let nItems = this._connectionItems.size; + this.item.visible = nItems > 0; + + super._sync(); + } + + get category() { + return NMConnectionCategory.VPN; + } + + _getDescription() { + return _("VPN"); + } + + _getStatus() { + let values = this._connectionItems.values(); + for (let item of values) { + if (item.isActive()) + return item.getName(); + } + + return _("VPN Off"); + } + + _getMenuIcon() { + return this.getIndicatorIcon() || 'network-vpn-symbolic'; + } + + activateConnection(connection) { + this._client.activate_connection_async(connection, null, null, null, null); + } + + deactivateConnection(activeConnection) { + this._client.deactivate_connection(activeConnection, null); + } + + setActiveConnections(vpnConnections) { + let connections = this._connectionItems.values(); + for (let item of connections) + item.setActiveConnection(null); + + vpnConnections.forEach(a => { + if (a.connection) { + let item = this._connectionItems.get(a.connection.get_uuid()); + item.setActiveConnection(a); + } + }); + } + + _makeConnectionItem(connection) { + return new NMVpnConnectionItem(this, connection); + } + + getIndicatorIcon() { + let items = this._connectionItems.values(); + for (let item of items) { + let icon = item.getIndicatorIcon(); + if (icon) + return icon; + } + return ''; + } +}; +Signals.addSignalMethods(NMVpnSection.prototype); + +var DeviceCategory = class extends PopupMenu.PopupMenuSection { + constructor(category) { + super(); + + this._category = category; + + this.devices = []; + + this.section = new PopupMenu.PopupMenuSection(); + this.section.box.connect('actor-added', this._sync.bind(this)); + this.section.box.connect('actor-removed', this._sync.bind(this)); + this.addMenuItem(this.section); + + this._summaryItem = new PopupMenu.PopupSubMenuMenuItem('', true); + this._summaryItem.icon.icon_name = this._getSummaryIcon(); + this.addMenuItem(this._summaryItem); + + this._summaryItem.menu.addSettingsAction(_('Network Settings'), + 'gnome-network-panel.desktop'); + this._summaryItem.hide(); + + } + + _sync() { + let nDevices = this.section.box.get_children().reduce( + (prev, child) => prev + (child.visible ? 1 : 0), 0); + this._summaryItem.label.text = this._getSummaryLabel(nDevices); + let shouldSummarize = nDevices > MAX_DEVICE_ITEMS; + this._summaryItem.visible = shouldSummarize; + this.section.actor.visible = !shouldSummarize; + } + + _getSummaryIcon() { + switch (this._category) { + case NMConnectionCategory.WIRED: + return 'network-wired-symbolic'; + case NMConnectionCategory.WIRELESS: + case NMConnectionCategory.WWAN: + return 'network-wireless-symbolic'; + } + return ''; + } + + _getSummaryLabel(nDevices) { + switch (this._category) { + case NMConnectionCategory.WIRED: + return ngettext("%s Wired Connection", + "%s Wired Connections", + nDevices).format(nDevices); + case NMConnectionCategory.WIRELESS: + return ngettext("%s Wi-Fi Connection", + "%s Wi-Fi Connections", + nDevices).format(nDevices); + case NMConnectionCategory.WWAN: + return ngettext("%s Modem Connection", + "%s Modem Connections", + nDevices).format(nDevices); + } + return ''; + } +}; + +var NMApplet = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._primaryIndicator = this._addIndicator(); + this._vpnIndicator = this._addIndicator(); + + // Device types + this._dtypes = { }; + this._dtypes[NM.DeviceType.ETHERNET] = NMDeviceWired; + this._dtypes[NM.DeviceType.WIFI] = NMDeviceWireless; + this._dtypes[NM.DeviceType.MODEM] = NMDeviceModem; + this._dtypes[NM.DeviceType.BT] = NMDeviceBluetooth; + + // Connection types + this._ctypes = { }; + this._ctypes[NM.SETTING_WIRED_SETTING_NAME] = NMConnectionCategory.WIRED; + this._ctypes[NM.SETTING_WIRELESS_SETTING_NAME] = NMConnectionCategory.WIRELESS; + this._ctypes[NM.SETTING_BLUETOOTH_SETTING_NAME] = NMConnectionCategory.WWAN; + this._ctypes[NM.SETTING_CDMA_SETTING_NAME] = NMConnectionCategory.WWAN; + this._ctypes[NM.SETTING_GSM_SETTING_NAME] = NMConnectionCategory.WWAN; + this._ctypes[NM.SETTING_VPN_SETTING_NAME] = NMConnectionCategory.VPN; + + this._getClient(); + } + + async _getClient() { + this._client = await NM.Client.new_async(null); + + this._activeConnections = []; + this._connections = []; + this._connectivityQueue = []; + + this._mainConnection = null; + this._mainConnectionIconChangedId = 0; + this._mainConnectionStateChangedId = 0; + + this._notification = null; + + this._nmDevices = []; + this._devices = { }; + + let categories = [NMConnectionCategory.WIRED, + NMConnectionCategory.WIRELESS, + NMConnectionCategory.WWAN]; + for (let category of categories) { + this._devices[category] = new DeviceCategory(category); + this.menu.addMenuItem(this._devices[category]); + } + + this._vpnSection = new NMVpnSection(this._client); + this._vpnSection.connect('activation-failed', this._onActivationFailed.bind(this)); + this._vpnSection.connect('icon-changed', this._updateIcon.bind(this)); + this.menu.addMenuItem(this._vpnSection.item); + + this._readConnections(); + this._readDevices(); + this._syncNMState(); + this._syncMainConnection(); + this._syncVpnConnections(); + + this._client.connect('notify::nm-running', this._syncNMState.bind(this)); + this._client.connect('notify::networking-enabled', this._syncNMState.bind(this)); + this._client.connect('notify::state', this._syncNMState.bind(this)); + this._client.connect('notify::primary-connection', this._syncMainConnection.bind(this)); + this._client.connect('notify::activating-connection', this._syncMainConnection.bind(this)); + this._client.connect('notify::active-connections', this._syncVpnConnections.bind(this)); + this._client.connect('notify::connectivity', this._syncConnectivity.bind(this)); + this._client.connect('device-added', this._deviceAdded.bind(this)); + this._client.connect('device-removed', this._deviceRemoved.bind(this)); + this._client.connect('connection-added', this._connectionAdded.bind(this)); + this._client.connect('connection-removed', this._connectionRemoved.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _ensureSource() { + if (!this._source) { + this._source = new MessageTray.Source(_("Network Manager"), + 'network-transmit-receive'); + this._source.policy = new MessageTray.NotificationApplicationPolicy('gnome-network-panel'); + + this._source.connect('destroy', () => (this._source = null)); + Main.messageTray.add(this._source); + } + } + + _readDevices() { + let devices = this._client.get_devices() || []; + for (let i = 0; i < devices.length; ++i) { + try { + this._deviceAdded(this._client, devices[i], true); + } catch (e) { + log('Failed to add device %s: %s'.format(devices[i], e.toString())); + } + } + this._syncDeviceNames(); + } + + _notify(iconName, title, text, urgency) { + if (this._notification) + this._notification.destroy(); + + this._ensureSource(); + + let gicon = new Gio.ThemedIcon({ name: iconName }); + this._notification = new MessageTray.Notification(this._source, title, text, { gicon }); + this._notification.setUrgency(urgency); + this._notification.setTransient(true); + this._notification.connect('destroy', () => { + this._notification = null; + }); + this._source.showNotification(this._notification); + } + + _onActivationFailed(_device, _reason) { + // XXX: nm-applet has no special text depending on reason + // but I'm not sure of this generic message + this._notify('network-error-symbolic', + _("Connection failed"), + _("Activation of network connection failed"), + MessageTray.Urgency.HIGH); + } + + _syncDeviceNames() { + let names = NM.Device.disambiguate_names(this._nmDevices); + for (let i = 0; i < this._nmDevices.length; i++) { + let device = this._nmDevices[i]; + let description = names[i]; + if (device._delegate) + device._delegate.setDeviceDescription(description); + } + } + + _deviceAdded(client, device, skipSyncDeviceNames) { + if (device._delegate) { + // already seen, not adding again + return; + } + + let wrapperClass = this._dtypes[device.get_device_type()]; + if (wrapperClass) { + let wrapper = new wrapperClass(this._client, device); + device._delegate = wrapper; + this._addDeviceWrapper(wrapper); + + this._nmDevices.push(device); + this._deviceChanged(device, skipSyncDeviceNames); + + device.connect('notify::interface', () => { + this._deviceChanged(device, false); + }); + } + } + + _deviceChanged(device, skipSyncDeviceNames) { + let wrapper = device._delegate; + + if (!skipSyncDeviceNames) + this._syncDeviceNames(); + + if (wrapper instanceof NMConnectionSection) { + this._connections.forEach(connection => { + wrapper.checkConnection(connection); + }); + } + } + + _addDeviceWrapper(wrapper) { + wrapper._activationFailedId = wrapper.connect('activation-failed', + this._onActivationFailed.bind(this)); + + let section = this._devices[wrapper.category].section; + section.addMenuItem(wrapper.item); + + let devices = this._devices[wrapper.category].devices; + devices.push(wrapper); + } + + _deviceRemoved(client, device) { + let pos = this._nmDevices.indexOf(device); + if (pos != -1) { + this._nmDevices.splice(pos, 1); + this._syncDeviceNames(); + } + + let wrapper = device._delegate; + if (!wrapper) { + log('Removing a network device that was not added'); + return; + } + + this._removeDeviceWrapper(wrapper); + } + + _removeDeviceWrapper(wrapper) { + wrapper.disconnect(wrapper._activationFailedId); + wrapper.destroy(); + + let devices = this._devices[wrapper.category].devices; + let pos = devices.indexOf(wrapper); + devices.splice(pos, 1); + } + + _getMainConnection() { + let connection; + + connection = this._client.get_primary_connection(); + if (connection) { + ensureActiveConnectionProps(connection); + return connection; + } + + connection = this._client.get_activating_connection(); + if (connection) { + ensureActiveConnectionProps(connection); + return connection; + } + + return null; + } + + _syncMainConnection() { + if (this._mainConnectionIconChangedId > 0) { + this._mainConnection._primaryDevice.disconnect(this._mainConnectionIconChangedId); + this._mainConnectionIconChangedId = 0; + } + + if (this._mainConnectionStateChangedId > 0) { + this._mainConnection.disconnect(this._mainConnectionStateChangedId); + this._mainConnectionStateChangedId = 0; + } + + this._mainConnection = this._getMainConnection(); + + if (this._mainConnection) { + if (this._mainConnection._primaryDevice) + this._mainConnectionIconChangedId = this._mainConnection._primaryDevice.connect('icon-changed', this._updateIcon.bind(this)); + this._mainConnectionStateChangedId = this._mainConnection.connect('notify::state', this._mainConnectionStateChanged.bind(this)); + this._mainConnectionStateChanged(); + } + + this._updateIcon(); + this._syncConnectivity(); + } + + _syncVpnConnections() { + let activeConnections = this._client.get_active_connections() || []; + let vpnConnections = activeConnections.filter( + a => a instanceof NM.VpnConnection); + vpnConnections.forEach(a => { + ensureActiveConnectionProps(a); + }); + this._vpnSection.setActiveConnections(vpnConnections); + + this._updateIcon(); + } + + _mainConnectionStateChanged() { + if (this._mainConnection.state == NM.ActiveConnectionState.ACTIVATED && this._notification) + this._notification.destroy(); + } + + _ignoreConnection(connection) { + let setting = connection.get_setting_connection(); + if (!setting) + return true; + + // Ignore slave connections + if (setting.get_master()) + return true; + + return false; + } + + _addConnection(connection) { + if (this._ignoreConnection(connection)) + return; + if (connection._updatedId) { + // connection was already seen + return; + } + + connection._updatedId = connection.connect('changed', this._updateConnection.bind(this)); + + this._updateConnection(connection); + this._connections.push(connection); + } + + _readConnections() { + let connections = this._client.get_connections(); + connections.forEach(this._addConnection.bind(this)); + } + + _connectionAdded(client, connection) { + this._addConnection(connection); + } + + _connectionRemoved(client, connection) { + let pos = this._connections.indexOf(connection); + if (pos != -1) + this._connections.splice(pos, 1); + + let section = connection._section; + + if (section == NMConnectionCategory.INVALID) + return; + + if (section == NMConnectionCategory.VPN) { + this._vpnSection.removeConnection(connection); + } else { + let devices = this._devices[section].devices; + for (let i = 0; i < devices.length; i++) { + if (devices[i] instanceof NMConnectionSection) + devices[i].removeConnection(connection); + } + } + + connection.disconnect(connection._updatedId); + connection._updatedId = 0; + } + + _updateConnection(connection) { + let connectionSettings = connection.get_setting_by_name(NM.SETTING_CONNECTION_SETTING_NAME); + connection._type = connectionSettings.type; + connection._section = this._ctypes[connection._type] || NMConnectionCategory.INVALID; + + let section = connection._section; + + if (section == NMConnectionCategory.INVALID) + return; + + if (section == NMConnectionCategory.VPN) { + this._vpnSection.checkConnection(connection); + } else { + let devices = this._devices[section].devices; + devices.forEach(wrapper => { + if (wrapper instanceof NMConnectionSection) + wrapper.checkConnection(connection); + }); + } + } + + _syncNMState() { + this.visible = this._client.nm_running; + this.menu.actor.visible = this._client.networking_enabled; + + this._updateIcon(); + this._syncConnectivity(); + } + + _flushConnectivityQueue() { + if (this._portalHelperProxy) { + for (let item of this._connectivityQueue) + this._portalHelperProxy.CloseRemote(item); + } + + this._connectivityQueue = []; + } + + _closeConnectivityCheck(path) { + let index = this._connectivityQueue.indexOf(path); + + if (index >= 0) { + if (this._portalHelperProxy) + this._portalHelperProxy.CloseRemote(path); + + this._connectivityQueue.splice(index, 1); + } + } + + async _portalHelperDone(proxy, emitter, parameters) { + let [path, result] = parameters; + + if (result == PortalHelperResult.CANCELLED) { + // Keep the connection in the queue, so the user is not + // spammed with more logins until we next flush the queue, + // which will happen once he chooses a better connection + // or we get to full connectivity through other means + } else if (result == PortalHelperResult.COMPLETED) { + this._closeConnectivityCheck(path); + } else if (result == PortalHelperResult.RECHECK) { + try { + const state = await this._client.check_connectivity_async(null); + if (state >= NM.ConnectivityState.FULL) + this._closeConnectivityCheck(path); + } catch (e) { } + } else { + log('Invalid result from portal helper: %s'.format(result)); + } + } + + _syncConnectivity() { + if (this._mainConnection == null || + this._mainConnection.state != NM.ActiveConnectionState.ACTIVATED) { + this._flushConnectivityQueue(); + return; + } + + let isPortal = this._client.connectivity == NM.ConnectivityState.PORTAL; + // For testing, allow interpreting any value != FULL as PORTAL, because + // LIMITED (no upstream route after the default gateway) is easy to obtain + // with a tethered phone + // NONE is also possible, with a connection configured to force no default route + // (but in general we should only prompt a portal if we know there is a portal) + if (GLib.getenv('GNOME_SHELL_CONNECTIVITY_TEST') != null) + isPortal = isPortal || this._client.connectivity < NM.ConnectivityState.FULL; + if (!isPortal || Main.sessionMode.isGreeter) + return; + + let path = this._mainConnection.get_path(); + for (let item of this._connectivityQueue) { + if (item == path) + return; + } + + let timestamp = global.get_current_time(); + if (this._portalHelperProxy) { + this._portalHelperProxy.AuthenticateRemote(path, '', timestamp); + } else { + new PortalHelperProxy(Gio.DBus.session, 'org.gnome.Shell.PortalHelper', + '/org/gnome/Shell/PortalHelper', (proxy, error) => { + if (error) { + log('Error launching the portal helper: %s'.format(error)); + return; + } + + this._portalHelperProxy = proxy; + proxy.connectSignal('Done', this._portalHelperDone.bind(this)); + + proxy.AuthenticateRemote(path, '', timestamp); + }); + } + + this._connectivityQueue.push(path); + } + + _updateIcon() { + if (!this._client.networking_enabled) { + this._primaryIndicator.visible = false; + } else { + let dev = null; + if (this._mainConnection) + dev = this._mainConnection._primaryDevice; + + let state = this._client.get_state(); + let connected = state == NM.State.CONNECTED_GLOBAL; + this._primaryIndicator.visible = (dev != null) || connected; + if (dev) { + this._primaryIndicator.icon_name = dev.getIndicatorIcon(); + } else if (connected) { + if (this._client.connectivity == NM.ConnectivityState.FULL) + this._primaryIndicator.icon_name = 'network-wired-symbolic'; + else + this._primaryIndicator.icon_name = 'network-wired-no-route-symbolic'; + } + } + + this._vpnIndicator.icon_name = this._vpnSection.getIndicatorIcon(); + this._vpnIndicator.visible = this._vpnIndicator.icon_name !== null; + } +}); diff --git a/js/ui/status/nightLight.js b/js/ui/status/nightLight.js new file mode 100644 index 0000000..c595c3d --- /dev/null +++ b/js/ui/status/nightLight.js @@ -0,0 +1,70 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GObject } = imports.gi; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Color'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Color'; + +const ColorInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Color'); +const ColorProxy = Gio.DBusProxy.makeProxyWrapper(ColorInterface); + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'night-light-symbolic'; + this._proxy = new ColorProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + this._proxy.connect('g-properties-changed', + this._sync.bind(this)); + this._sync(); + }); + + this._item = new PopupMenu.PopupSubMenuMenuItem("", true); + this._item.icon.icon_name = 'night-light-symbolic'; + this._disableItem = this._item.menu.addAction('', () => { + this._proxy.DisabledUntilTomorrow = !this._proxy.DisabledUntilTomorrow; + }); + this._item.menu.addAction(_("Turn Off"), () => { + let settings = new Gio.Settings({ schema_id: 'org.gnome.settings-daemon.plugins.color' }); + settings.set_boolean('night-light-enabled', false); + }); + this._item.menu.addSettingsAction(_("Display Settings"), 'gnome-display-panel.desktop'); + this.menu.addMenuItem(this._item); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + this._sync(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _sync() { + let visible = this._proxy.NightLightActive; + let disabled = this._proxy.DisabledUntilTomorrow; + + this._item.label.text = disabled + ? _("Night Light Disabled") + : _("Night Light On"); + this._disableItem.label.text = disabled + ? _("Resume") + : _("Disable Until Tomorrow"); + this._item.visible = this._indicator.visible = visible; + } +}); diff --git a/js/ui/status/power.js b/js/ui/status/power.js new file mode 100644 index 0000000..ca85a98 --- /dev/null +++ b/js/ui/status/power.js @@ -0,0 +1,155 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Clutter, Gio, GObject, St, UPowerGlib: UPower } = imports.gi; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.freedesktop.UPower'; +const OBJECT_PATH = '/org/freedesktop/UPower/devices/DisplayDevice'; + +const DisplayDeviceInterface = loadInterfaceXML('org.freedesktop.UPower.Device'); +const PowerManagerProxy = Gio.DBusProxy.makeProxyWrapper(DisplayDeviceInterface); + +const SHOW_BATTERY_PERCENTAGE = 'show-battery-percentage'; + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._desktopSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.interface', + }); + this._desktopSettings.connect( + 'changed::%s'.format(SHOW_BATTERY_PERCENTAGE), this._sync.bind(this)); + + this._indicator = this._addIndicator(); + this._percentageLabel = new St.Label({ + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._percentageLabel); + this.add_style_class_name('power-status'); + + this._proxy = new PowerManagerProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + } else { + this._proxy.connect('g-properties-changed', + this._sync.bind(this)); + } + this._sync(); + }); + + this._item = new PopupMenu.PopupSubMenuMenuItem('', true); + this._item.menu.addSettingsAction(_('Power Settings'), + 'gnome-power-panel.desktop'); + this.menu.addMenuItem(this._item); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _getStatus() { + let seconds = 0; + + if (this._proxy.State === UPower.DeviceState.FULLY_CHARGED) + return _('Fully Charged'); + else if (this._proxy.State === UPower.DeviceState.CHARGING) + seconds = this._proxy.TimeToFull; + else if (this._proxy.State === UPower.DeviceState.DISCHARGING) + seconds = this._proxy.TimeToEmpty; + else if (this._proxy.State === UPower.DeviceState.PENDING_CHARGE) + return _('Not Charging'); + // state is PENDING_DISCHARGE + else + return _('Estimating…'); + + let time = Math.round(seconds / 60); + if (time === 0) { + // 0 is reported when UPower does not have enough data + // to estimate battery life + return _('Estimating…'); + } + + let minutes = time % 60; + let hours = Math.floor(time / 60); + + if (this._proxy.State === UPower.DeviceState.DISCHARGING) { + // Translators: this is <hours>:<minutes> Remaining (<percentage>) + return _('%d\u2236%02d Remaining (%d\u2009%%)').format( + hours, minutes, this._proxy.Percentage); + } + + if (this._proxy.State === UPower.DeviceState.CHARGING) { + // Translators: this is <hours>:<minutes> Until Full (<percentage>) + return _('%d\u2236%02d Until Full (%d\u2009%%)').format( + hours, minutes, this._proxy.Percentage); + } + + return null; + } + + _sync() { + // Do we have batteries or a UPS? + let visible = this._proxy.IsPresent; + if (visible) { + this._item.show(); + this._percentageLabel.visible = + this._desktopSettings.get_boolean(SHOW_BATTERY_PERCENTAGE); + } else { + // If there's no battery, then we use the power icon. + this._item.hide(); + this._indicator.icon_name = 'system-shutdown-symbolic'; + this._percentageLabel.hide(); + return; + } + + // The icons + let chargingState = this._proxy.State === UPower.DeviceState.CHARGING + ? '-charging' : ''; + let fillLevel = 10 * Math.floor(this._proxy.Percentage / 10); + const charged = + this._proxy.State === UPower.DeviceState.FULLY_CHARGED || + (this._proxy.State === UPower.DeviceState.CHARGING && fillLevel === 100); + const icon = charged + ? 'battery-level-100-charged-symbolic' + : 'battery-level-%d%s-symbolic'.format(fillLevel, chargingState); + + // Make sure we fall back to fallback-icon-name and not GThemedIcon's + // default fallbacks + let gicon = new Gio.ThemedIcon({ + name: icon, + use_default_fallbacks: false, + }); + + this._indicator.gicon = gicon; + this._item.icon.gicon = gicon; + + let fallbackIcon = this._proxy.IconName; + this._indicator.fallback_icon_name = fallbackIcon; + this._item.icon.fallback_icon_name = fallbackIcon; + + // The icon label + let label; + if (this._proxy.State == UPower.DeviceState.FULLY_CHARGED) + label = _("%d\u2009%%").format(100); + else + label = _("%d\u2009%%").format(this._proxy.Percentage); + this._percentageLabel.text = label; + + // The status label + this._item.label.text = this._getStatus(); + } +}); diff --git a/js/ui/status/remoteAccess.js b/js/ui/status/remoteAccess.js new file mode 100644 index 0000000..21f6581 --- /dev/null +++ b/js/ui/status/remoteAccess.js @@ -0,0 +1,97 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported RemoteAccessApplet */ + +const { GObject, Meta } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +var RemoteAccessApplet = GObject.registerClass( +class RemoteAccessApplet extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + let controller = global.backend.get_remote_access_controller(); + + if (!controller) + return; + + this._handles = new Set(); + this._sharedIndicator = null; + this._recordingIndicator = null; + this._menuSection = null; + + controller.connect('new-handle', (o, handle) => { + this._onNewHandle(handle); + }); + } + + _ensureControls() { + if (this._sharedIndicator && this._recordingIndicator) + return; + + this._sharedIndicator = this._addIndicator(); + this._sharedIndicator.icon_name = 'screen-shared-symbolic'; + this._sharedIndicator.add_style_class_name('remote-access-indicator'); + + this._sharedItem = + new PopupMenu.PopupSubMenuMenuItem(_("Screen is Being Shared"), + true); + this._sharedItem.menu.addAction(_("Turn off"), + () => { + for (let handle of this._handles) { + if (!handle.is_recording) + handle.stop(); + } + }); + this._sharedItem.icon.icon_name = 'screen-shared-symbolic'; + this.menu.addMenuItem(this._sharedItem); + + this._recordingIndicator = this._addIndicator(); + this._recordingIndicator.icon_name = 'media-record-symbolic'; + this._recordingIndicator.add_style_class_name('screencast-indicator'); + } + + _isScreenShared() { + return [...this._handles].some(handle => !handle.is_recording); + } + + _isRecording() { + return [...this._handles].some(handle => handle.is_recording); + } + + _sync() { + if (this._isScreenShared()) { + this._sharedIndicator.visible = true; + this._sharedItem.visible = true; + } else { + this._sharedIndicator.visible = false; + this._sharedItem.visible = false; + } + + this._recordingIndicator.visible = this._isRecording(); + } + + _onStopped(handle) { + this._handles.delete(handle); + this._sync(); + } + + _onNewHandle(handle) { + // We can't possibly know about all types of screen sharing on X11, so + // showing these controls on X11 might give a false sense of security. + // Thus, only enable these controls when using Wayland, where we are + // in control of sharing. + // + // We still want to show screen recordings though, to indicate when + // the built in screen recorder is active, no matter the session type. + if (!Meta.is_wayland_compositor() && !handle.is_recording) + return; + + this._handles.add(handle); + handle.connect('stopped', this._onStopped.bind(this)); + + this._ensureControls(); + this._sync(); + } +}); diff --git a/js/ui/status/rfkill.js b/js/ui/status/rfkill.js new file mode 100644 index 0000000..9f8b09d --- /dev/null +++ b/js/ui/status/rfkill.js @@ -0,0 +1,112 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GObject } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill'; + +const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill'); +const RfkillManagerProxy = Gio.DBusProxy.makeProxyWrapper(RfkillManagerInterface); + +var RfkillManager = class { + constructor() { + this._proxy = new RfkillManagerProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + this._proxy.connect('g-properties-changed', + this._changed.bind(this)); + this._changed(); + }); + } + + get airplaneMode() { + return this._proxy.AirplaneMode; + } + + set airplaneMode(v) { + this._proxy.AirplaneMode = v; + } + + get hwAirplaneMode() { + return this._proxy.HardwareAirplaneMode; + } + + get shouldShowAirplaneMode() { + return this._proxy.ShouldShowAirplaneMode; + } + + _changed() { + this.emit('airplane-mode-changed'); + } +}; +Signals.addSignalMethods(RfkillManager.prototype); + +var _manager; +function getRfkillManager() { + if (_manager != null) + return _manager; + + _manager = new RfkillManager(); + return _manager; +} + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._manager = getRfkillManager(); + this._manager.connect('airplane-mode-changed', this._sync.bind(this)); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'airplane-mode-symbolic'; + this._indicator.hide(); + + // The menu only appears when airplane mode is on, so just + // statically build it as if it was on, rather than dynamically + // changing the menu contents. + this._item = new PopupMenu.PopupSubMenuMenuItem(_("Airplane Mode On"), true); + this._item.icon.icon_name = 'airplane-mode-symbolic'; + this._offItem = this._item.menu.addAction(_("Turn Off"), () => { + this._manager.airplaneMode = false; + }); + this._item.menu.addSettingsAction(_("Network Settings"), 'gnome-network-panel.desktop'); + this.menu.addMenuItem(this._item); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + + this._sync(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _sync() { + let airplaneMode = this._manager.airplaneMode; + let hwAirplaneMode = this._manager.hwAirplaneMode; + let showAirplaneMode = this._manager.shouldShowAirplaneMode; + + this._indicator.visible = airplaneMode && showAirplaneMode; + this._item.visible = airplaneMode && showAirplaneMode; + this._offItem.setSensitive(!hwAirplaneMode); + + if (hwAirplaneMode) + this._offItem.label.text = _("Use hardware switch to turn off"); + else + this._offItem.label.text = _("Turn Off"); + } +}); diff --git a/js/ui/status/system.js b/js/ui/status/system.js new file mode 100644 index 0000000..6f71109 --- /dev/null +++ b/js/ui/status/system.js @@ -0,0 +1,178 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { GObject, Shell, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const SystemActions = imports.misc.systemActions; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._systemActions = new SystemActions.getDefault(); + + this._createSubMenu(); + + this._loginScreenItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._logoutItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._suspendItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._powerOffItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._restartItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + // Whether shutdown is available or not depends on both lockdown + // settings (disable-log-out) and Polkit policy - the latter doesn't + // notify, so we update the menu item each time the menu opens or + // the lockdown setting changes, which should be close enough. + this.menu.connect('open-state-changed', (menu, open) => { + if (!open) + return; + + this._systemActions.forceUpdate(); + }); + this._updateSessionSubMenu(); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + this._settingsItem.visible = Main.sessionMode.allowSettings; + } + + _updateSessionSubMenu() { + this._sessionSubMenu.visible = + this._loginScreenItem.visible || + this._logoutItem.visible || + this._suspendItem.visible || + this._restartItem.visible || + this._powerOffItem.visible; + } + + _createSubMenu() { + let bindFlags = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE; + let item; + + item = new PopupMenu.PopupImageMenuItem( + this._systemActions.getName('lock-orientation'), + this._systemActions.orientation_lock_icon); + + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateLockOrientation(); + }); + this.menu.addMenuItem(item); + this._orientationLockItem = item; + this._systemActions.bind_property('can-lock-orientation', + this._orientationLockItem, 'visible', + bindFlags); + this._systemActions.connect('notify::orientation-lock-icon', () => { + let iconName = this._systemActions.orientation_lock_icon; + let labelText = this._systemActions.getName("lock-orientation"); + + this._orientationLockItem.setIcon(iconName); + this._orientationLockItem.label.text = labelText; + }); + + let app = this._settingsApp = Shell.AppSystem.get_default().lookup_app( + 'gnome-control-center.desktop'); + if (app) { + const [icon] = app.app_info.get_icon().names; + const name = app.app_info.get_name(); + item = new PopupMenu.PopupImageMenuItem(name, icon); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + Main.overview.hide(); + this._settingsApp.activate(); + }); + this.menu.addMenuItem(item); + this._settingsItem = item; + } else { + log('Missing required core component Settings, expect trouble…'); + this._settingsItem = new St.Widget(); + } + + item = new PopupMenu.PopupImageMenuItem(_('Lock'), 'changes-prevent-symbolic'); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateLockScreen(); + }); + this.menu.addMenuItem(item); + this._lockScreenItem = item; + this._systemActions.bind_property('can-lock-screen', + this._lockScreenItem, 'visible', + bindFlags); + + this._sessionSubMenu = new PopupMenu.PopupSubMenuMenuItem( + _('Power Off / Log Out'), true); + this._sessionSubMenu.icon.icon_name = 'system-shutdown-symbolic'; + + item = new PopupMenu.PopupMenuItem(_('Suspend')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateSuspend(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._suspendItem = item; + this._systemActions.bind_property('can-suspend', + this._suspendItem, 'visible', + bindFlags); + + item = new PopupMenu.PopupMenuItem(_('Restart…')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateRestart(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._restartItem = item; + this._systemActions.bind_property('can-restart', + this._restartItem, 'visible', + bindFlags); + + item = new PopupMenu.PopupMenuItem(_('Power Off…')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activatePowerOff(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._powerOffItem = item; + this._systemActions.bind_property('can-power-off', + this._powerOffItem, 'visible', + bindFlags); + + this._sessionSubMenu.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + item = new PopupMenu.PopupMenuItem(_('Log Out')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateLogout(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._logoutItem = item; + this._systemActions.bind_property('can-logout', + this._logoutItem, 'visible', + bindFlags); + + item = new PopupMenu.PopupMenuItem(_('Switch User…')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateSwitchUser(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._loginScreenItem = item; + this._systemActions.bind_property('can-switch-user', + this._loginScreenItem, 'visible', + bindFlags); + + this.menu.addMenuItem(this._sessionSubMenu); + } +}); diff --git a/js/ui/status/thunderbolt.js b/js/ui/status/thunderbolt.js new file mode 100644 index 0000000..d98355d --- /dev/null +++ b/js/ui/status/thunderbolt.js @@ -0,0 +1,340 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +// the following is a modified version of bolt/contrib/js/client.js + +const { Gio, GLib, GObject, Polkit, Shell } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const PanelMenu = imports.ui.panelMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +/* Keep in sync with data/org.freedesktop.bolt.xml */ + +const BoltClientInterface = loadInterfaceXML('org.freedesktop.bolt1.Manager'); +const BoltDeviceInterface = loadInterfaceXML('org.freedesktop.bolt1.Device'); + +const BoltDeviceProxy = Gio.DBusProxy.makeProxyWrapper(BoltDeviceInterface); + +/* */ + +var Status = { + DISCONNECTED: 'disconnected', + CONNECTING: 'connecting', + CONNECTED: 'connected', + AUTHORIZING: 'authorizing', + AUTH_ERROR: 'auth-error', + AUTHORIZED: 'authorized', +}; + +var Policy = { + DEFAULT: 'default', + MANUAL: 'manual', + AUTO: 'auto', +}; + +var AuthCtrl = { + NONE: 'none', +}; + +var AuthMode = { + DISABLED: 'disabled', + ENABLED: 'enabled', +}; + +const BOLT_DBUS_CLIENT_IFACE = 'org.freedesktop.bolt1.Manager'; +const BOLT_DBUS_NAME = 'org.freedesktop.bolt'; +const BOLT_DBUS_PATH = '/org/freedesktop/bolt'; + +var Client = class { + constructor() { + this._proxy = null; + this.probing = false; + this._getProxy(); + } + + async _getProxy() { + let nodeInfo = Gio.DBusNodeInfo.new_for_xml(BoltClientInterface); + try { + this._proxy = await Gio.DBusProxy.new( + Gio.DBus.system, + Gio.DBusProxyFlags.DO_NOT_AUTO_START, + nodeInfo.lookup_interface(BOLT_DBUS_CLIENT_IFACE), + BOLT_DBUS_NAME, + BOLT_DBUS_PATH, + BOLT_DBUS_CLIENT_IFACE, + null); + } catch (e) { + log('error creating bolt proxy: %s'.format(e.message)); + return; + } + this._propsChangedId = this._proxy.connect('g-properties-changed', this._onPropertiesChanged.bind(this)); + this._deviceAddedId = this._proxy.connectSignal('DeviceAdded', this._onDeviceAdded.bind(this)); + + this.probing = this._proxy.Probing; + if (this.probing) + this.emit('probing-changed', this.probing); + + } + + _onPropertiesChanged(proxy, properties) { + let unpacked = properties.deep_unpack(); + if (!('Probing' in unpacked)) + return; + + this.probing = this._proxy.Probing; + this.emit('probing-changed', this.probing); + } + + _onDeviceAdded(proxy, emitter, params) { + let [path] = params; + let device = new BoltDeviceProxy(Gio.DBus.system, + BOLT_DBUS_NAME, + path); + this.emit('device-added', device); + } + + /* public methods */ + close() { + if (!this._proxy) + return; + + this._proxy.disconnectSignal(this._deviceAddedId); + this._proxy.disconnect(this._propsChangedId); + this._proxy = null; + } + + enrollDevice(id, policy, callback) { + this._proxy.EnrollDeviceRemote(id, policy, AuthCtrl.NONE, (res, error) => { + if (error) { + Gio.DBusError.strip_remote_error(error); + callback(null, error); + return; + } + + let [path] = res; + let device = new BoltDeviceProxy(Gio.DBus.system, + BOLT_DBUS_NAME, + path); + callback(device, null); + }); + } + + get authMode() { + return this._proxy.AuthMode; + } +}; +Signals.addSignalMethods(Client.prototype); + +/* helper class to automatically authorize new devices */ +var AuthRobot = class { + constructor(client) { + this._client = client; + + this._devicesToEnroll = []; + this._enrolling = false; + + this._client.connect('device-added', this._onDeviceAdded.bind(this)); + } + + close() { + this.disconnectAll(); + this._client = null; + } + + /* the "device-added" signal will be emitted by boltd for every + * device that is not currently stored in the database. We are + * only interested in those devices, because all known devices + * will be handled by the user himself */ + _onDeviceAdded(cli, dev) { + if (dev.Status !== Status.CONNECTED) + return; + + /* check if authorization is enabled in the daemon. if not + * we won't even bother authorizing, because we will only + * get an error back. The exact contents of AuthMode might + * change in the future, but must contain AuthMode.ENABLED + * if it is enabled. */ + if (!cli.authMode.split('|').includes(AuthMode.ENABLED)) + return; + + /* check if we should enroll the device */ + let res = [false]; + this.emit('enroll-device', dev, res); + if (res[0] !== true) + return; + + /* ok, we should authorize the device, add it to the back + * of the list */ + this._devicesToEnroll.push(dev); + this._enrollDevices(); + } + + /* The enrollment queue: + * - new devices will be added to the end of the array. + * - an idle callback will be scheduled that will keep + * calling itself as long as there a devices to be + * enrolled. + */ + _enrollDevices() { + if (this._enrolling) + return; + + this._enrolling = true; + GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._enrollDevicesIdle.bind(this)); + } + + _onEnrollDone(device, error) { + if (error) + this.emit('enroll-failed', device, error); + + /* TODO: scan the list of devices to be authorized for children + * of this device and remove them (and their children and + * their children and ....) from the device queue + */ + this._enrolling = this._devicesToEnroll.length > 0; + + if (this._enrolling) { + GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._enrollDevicesIdle.bind(this)); + } + } + + _enrollDevicesIdle() { + let devices = this._devicesToEnroll; + + let dev = devices.shift(); + if (dev === undefined) + return GLib.SOURCE_REMOVE; + + this._client.enrollDevice(dev.Uid, + Policy.DEFAULT, + this._onEnrollDone.bind(this)); + return GLib.SOURCE_REMOVE; + } +}; +Signals.addSignalMethods(AuthRobot.prototype); + +/* eof client.js */ + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'thunderbolt-symbolic'; + + this._client = new Client(); + this._client.connect('probing-changed', this._onProbing.bind(this)); + + this._robot = new AuthRobot(this._client); + + this._robot.connect('enroll-device', this._onEnrollDevice.bind(this)); + this._robot.connect('enroll-failed', this._onEnrollFailed.bind(this)); + + Main.sessionMode.connect('updated', this._sync.bind(this)); + this._sync(); + + this._source = null; + this._perm = null; + this._createPermission(); + } + + async _createPermission() { + try { + this._perm = await Polkit.Permission.new('org.freedesktop.bolt.enroll', null, null); + } catch (e) { + log('Failed to get PolKit permission: %s'.format(e.toString())); + } + } + + _onDestroy() { + this._robot.close(); + this._client.close(); + } + + _ensureSource() { + if (!this._source) { + this._source = new MessageTray.Source(_("Thunderbolt"), + 'thunderbolt-symbolic'); + this._source.connect('destroy', () => (this._source = null)); + + Main.messageTray.add(this._source); + } + + return this._source; + } + + _notify(title, body) { + if (this._notification) + this._notification.destroy(); + + let source = this._ensureSource(); + + this._notification = new MessageTray.Notification(source, title, body); + this._notification.setUrgency(MessageTray.Urgency.HIGH); + this._notification.connect('destroy', () => { + this._notification = null; + }); + this._notification.connect('activated', () => { + let app = Shell.AppSystem.get_default().lookup_app('gnome-thunderbolt-panel.desktop'); + if (app) + app.activate(); + }); + this._source.showNotification(this._notification); + } + + /* Session callbacks */ + _sync() { + let active = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this._indicator.visible = active && this._client.probing; + } + + /* Bolt.Client callbacks */ + _onProbing(cli, probing) { + if (probing) + this._indicator.icon_name = 'thunderbolt-acquiring-symbolic'; + else + this._indicator.icon_name = 'thunderbolt-symbolic'; + + this._sync(); + } + + /* AuthRobot callbacks */ + _onEnrollDevice(obj, device, policy) { + /* only authorize new devices when in an unlocked user session */ + let unlocked = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + /* and if we have the permission to do so, otherwise we trigger a PolKit dialog */ + let allowed = this._perm && this._perm.allowed; + + let auth = unlocked && allowed; + policy[0] = auth; + + log('thunderbolt: [%s] auto enrollment: %s (allowed: %s)'.format( + device.Name, auth ? 'yes' : 'no', allowed ? 'yes' : 'no')); + + if (auth) + return; /* we are done */ + + if (!unlocked) { + const title = _("Unknown Thunderbolt device"); + const body = _("New device has been detected while you were away. Please disconnect and reconnect the device to start using it."); + this._notify(title, body); + } else { + const title = _("Unauthorized Thunderbolt device"); + const body = _("New device has been detected and needs to be authorized by an administrator."); + this._notify(title, body); + } + } + + _onEnrollFailed(obj, device, error) { + const title = _("Thunderbolt authorization error"); + const body = _("Could not authorize the Thunderbolt device: %s").format(error.message); + this._notify(title, body); + } +}); diff --git a/js/ui/status/volume.js b/js/ui/status/volume.js new file mode 100644 index 0000000..7b50658 --- /dev/null +++ b/js/ui/status/volume.js @@ -0,0 +1,430 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Clutter, Gio, GLib, GObject, Gvc, St } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const Slider = imports.ui.slider; + +const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent'; + +// Each Gvc.MixerControl is a connection to PulseAudio, +// so it's better to make it a singleton +let _mixerControl; +function getMixerControl() { + if (_mixerControl) + return _mixerControl; + + _mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' }); + _mixerControl.open(); + + return _mixerControl; +} + +var StreamSlider = class { + constructor(control) { + this._control = control; + + this.item = new PopupMenu.PopupBaseMenuItem({ activate: false }); + + this._inDrag = false; + this._notifyVolumeChangeId = 0; + + this._slider = new Slider.Slider(0); + + this._soundSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.sound' }); + this._soundSettings.connect('changed::%s'.format(ALLOW_AMPLIFIED_VOLUME_KEY), this._amplifySettingsChanged.bind(this)); + this._amplifySettingsChanged(); + + this._sliderChangedId = this._slider.connect('notify::value', + this._sliderChanged.bind(this)); + this._slider.connect('drag-begin', () => (this._inDrag = true)); + this._slider.connect('drag-end', () => { + this._inDrag = false; + this._notifyVolumeChange(); + }); + + this._icon = new St.Icon({ style_class: 'popup-menu-icon' }); + this.item.add(this._icon); + this.item.add_child(this._slider); + this.item.connect('button-press-event', (actor, event) => { + return this._slider.startDragging(event); + }); + this.item.connect('key-press-event', (actor, event) => { + return this._slider.emit('key-press-event', event); + }); + this.item.connect('scroll-event', (actor, event) => { + return this._slider.emit('scroll-event', event); + }); + + this._stream = null; + this._volumeCancellable = null; + this._icons = []; + } + + get stream() { + return this._stream; + } + + set stream(stream) { + if (this._stream) + this._disconnectStream(this._stream); + + this._stream = stream; + + if (this._stream) { + this._connectStream(this._stream); + this._updateVolume(); + } else { + this.emit('stream-updated'); + } + + this._updateVisibility(); + } + + _disconnectStream(stream) { + stream.disconnect(this._mutedChangedId); + this._mutedChangedId = 0; + stream.disconnect(this._volumeChangedId); + this._volumeChangedId = 0; + } + + _connectStream(stream) { + this._mutedChangedId = stream.connect('notify::is-muted', this._updateVolume.bind(this)); + this._volumeChangedId = stream.connect('notify::volume', this._updateVolume.bind(this)); + } + + _shouldBeVisible() { + return this._stream != null; + } + + _updateVisibility() { + let visible = this._shouldBeVisible(); + this.item.visible = visible; + } + + scroll(event) { + return this._slider.scroll(event); + } + + _sliderChanged() { + if (!this._stream) + return; + + let value = this._slider.value; + let volume = value * this._control.get_vol_max_norm(); + let prevMuted = this._stream.is_muted; + let prevVolume = this._stream.volume; + if (volume < 1) { + this._stream.volume = 0; + if (!prevMuted) + this._stream.change_is_muted(true); + } else { + this._stream.volume = volume; + if (prevMuted) + this._stream.change_is_muted(false); + } + this._stream.push_volume(); + + let volumeChanged = this._stream.volume !== prevVolume; + if (volumeChanged && !this._notifyVolumeChangeId && !this._inDrag) { + this._notifyVolumeChangeId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { + this._notifyVolumeChange(); + this._notifyVolumeChangeId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._notifyVolumeChangeId, + '[gnome-shell] this._notifyVolumeChangeId'); + } + } + + _notifyVolumeChange() { + if (this._volumeCancellable) + this._volumeCancellable.cancel(); + this._volumeCancellable = null; + + if (this._stream.state === Gvc.MixerStreamState.RUNNING) + return; // feedback not necessary while playing + + this._volumeCancellable = new Gio.Cancellable(); + let player = global.display.get_sound_player(); + player.play_from_theme('audio-volume-change', + _("Volume changed"), + this._volumeCancellable); + } + + _changeSlider(value) { + this._slider.block_signal_handler(this._sliderChangedId); + this._slider.value = value; + this._slider.unblock_signal_handler(this._sliderChangedId); + } + + _updateVolume() { + let muted = this._stream.is_muted; + this._changeSlider(muted + ? 0 : this._stream.volume / this._control.get_vol_max_norm()); + this.emit('stream-updated'); + } + + _amplifySettingsChanged() { + this._allowAmplified = this._soundSettings.get_boolean(ALLOW_AMPLIFIED_VOLUME_KEY); + + this._slider.maximum_value = this._allowAmplified + ? this.getMaxLevel() : 1; + + if (this._stream) + this._updateVolume(); + } + + getIcon() { + if (!this._stream) + return null; + + let volume = this._stream.volume; + let n; + if (this._stream.is_muted || volume <= 0) { + n = 0; + } else { + n = Math.ceil(3 * volume / this._control.get_vol_max_norm()); + n = Math.clamp(n, 1, this._icons.length - 1); + } + return this._icons[n]; + } + + getLevel() { + if (!this._stream) + return null; + + return this._stream.volume / this._control.get_vol_max_norm(); + } + + getMaxLevel() { + let maxVolume = this._control.get_vol_max_norm(); + if (this._allowAmplified) + maxVolume = this._control.get_vol_max_amplified(); + + return maxVolume / this._control.get_vol_max_norm(); + } +}; +Signals.addSignalMethods(StreamSlider.prototype); + +var OutputStreamSlider = class extends StreamSlider { + constructor(control) { + super(control); + this._slider.accessible_name = _("Volume"); + this._icons = [ + 'audio-volume-muted-symbolic', + 'audio-volume-low-symbolic', + 'audio-volume-medium-symbolic', + 'audio-volume-high-symbolic', + 'audio-volume-overamplified-symbolic', + ]; + } + + _connectStream(stream) { + super._connectStream(stream); + this._portChangedId = stream.connect('notify::port', this._portChanged.bind(this)); + this._portChanged(); + } + + _findHeadphones(sink) { + // This only works for external headphones (e.g. bluetooth) + if (sink.get_form_factor() == 'headset' || + sink.get_form_factor() == 'headphone') + return true; + + // a bit hackish, but ALSA/PulseAudio have a number + // of different identifiers for headphones, and I could + // not find the complete list + if (sink.get_ports().length > 0) + return sink.get_port().port.includes('headphone'); + + return false; + } + + _disconnectStream(stream) { + super._disconnectStream(stream); + stream.disconnect(this._portChangedId); + this._portChangedId = 0; + } + + _updateSliderIcon() { + this._icon.icon_name = this._hasHeadphones + ? 'audio-headphones-symbolic' + : 'audio-speakers-symbolic'; + } + + _portChanged() { + let hasHeadphones = this._findHeadphones(this._stream); + if (hasHeadphones != this._hasHeadphones) { + this._hasHeadphones = hasHeadphones; + this._updateSliderIcon(); + } + } +}; + +var InputStreamSlider = class extends StreamSlider { + constructor(control) { + super(control); + this._slider.accessible_name = _("Microphone"); + this._control.connect('stream-added', this._maybeShowInput.bind(this)); + this._control.connect('stream-removed', this._maybeShowInput.bind(this)); + this._icon.icon_name = 'audio-input-microphone-symbolic'; + this._icons = [ + 'microphone-sensitivity-muted-symbolic', + 'microphone-sensitivity-low-symbolic', + 'microphone-sensitivity-medium-symbolic', + 'microphone-sensitivity-high-symbolic', + ]; + } + + _connectStream(stream) { + super._connectStream(stream); + this._maybeShowInput(); + } + + _maybeShowInput() { + // only show input widgets if any application is recording audio + let showInput = false; + if (this._stream) { + // skip gnome-volume-control and pavucontrol which appear + // as recording because they show the input level + let skippedApps = [ + 'org.gnome.VolumeControl', + 'org.PulseAudio.pavucontrol', + ]; + + showInput = this._control.get_source_outputs().some(output => { + return !skippedApps.includes(output.get_application_id()); + }); + } + + this._showInput = showInput; + this._updateVisibility(); + } + + _shouldBeVisible() { + return super._shouldBeVisible() && this._showInput; + } +}; + +var VolumeMenu = class extends PopupMenu.PopupMenuSection { + constructor(control) { + super(); + + this.hasHeadphones = false; + + this._control = control; + this._control.connect('state-changed', this._onControlStateChanged.bind(this)); + this._control.connect('default-sink-changed', this._readOutput.bind(this)); + this._control.connect('default-source-changed', this._readInput.bind(this)); + + this._output = new OutputStreamSlider(this._control); + this._output.connect('stream-updated', () => { + this.emit('output-icon-changed'); + }); + this.addMenuItem(this._output.item); + + this._input = new InputStreamSlider(this._control); + this._input.item.connect('notify::visible', () => { + this.emit('input-visible-changed'); + }); + this._input.connect('stream-updated', () => { + this.emit('input-icon-changed'); + }); + this.addMenuItem(this._input.item); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._onControlStateChanged(); + } + + scroll(event) { + return this._output.scroll(event); + } + + _onControlStateChanged() { + if (this._control.get_state() == Gvc.MixerControlState.READY) { + this._readInput(); + this._readOutput(); + } else { + this.emit('output-icon-changed'); + } + } + + _readOutput() { + this._output.stream = this._control.get_default_sink(); + } + + _readInput() { + this._input.stream = this._control.get_default_source(); + } + + getOutputIcon() { + return this._output.getIcon(); + } + + getInputIcon() { + return this._input.getIcon(); + } + + getLevel() { + return this._output.getLevel(); + } + + getMaxLevel() { + return this._output.getMaxLevel(); + } + + getInputVisible() { + return this._input.item.visible; + } +}; + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._primaryIndicator = this._addIndicator(); + this._inputIndicator = this._addIndicator(); + + this._control = getMixerControl(); + this._volumeMenu = new VolumeMenu(this._control); + this._volumeMenu.connect('output-icon-changed', () => { + let icon = this._volumeMenu.getOutputIcon(); + + if (icon != null) + this._primaryIndicator.icon_name = icon; + this._primaryIndicator.visible = icon !== null; + }); + + this._inputIndicator.visible = this._volumeMenu.getInputVisible(); + this._volumeMenu.connect('input-visible-changed', () => { + this._inputIndicator.visible = this._volumeMenu.getInputVisible(); + }); + this._volumeMenu.connect('input-icon-changed', () => { + let icon = this._volumeMenu.getInputIcon(); + + if (icon !== null) + this._inputIndicator.icon_name = icon; + }); + + this.menu.addMenuItem(this._volumeMenu); + } + + vfunc_scroll_event() { + let result = this._volumeMenu.scroll(Clutter.get_current_event()); + if (result == Clutter.EVENT_PROPAGATE || this.menu.actor.mapped) + return result; + + let gicon = new Gio.ThemedIcon({ name: this._volumeMenu.getOutputIcon() }); + let level = this._volumeMenu.getLevel(); + let maxLevel = this._volumeMenu.getMaxLevel(); + Main.osdWindowManager.show(-1, gicon, null, level, maxLevel); + return result; + } +}); |