summaryrefslogtreecommitdiffstats
path: root/js/ui/status
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--js/ui/status/accessibility.js153
-rw-r--r--js/ui/status/autoRotate.js45
-rw-r--r--js/ui/status/bluetooth.js211
-rw-r--r--js/ui/status/brightness.js64
-rw-r--r--js/ui/status/darkMode.js49
-rw-r--r--js/ui/status/dwellClick.js83
-rw-r--r--js/ui/status/keyboard.js1095
-rw-r--r--js/ui/status/location.js371
-rw-r--r--js/ui/status/network.js2095
-rw-r--r--js/ui/status/nightLight.js70
-rw-r--r--js/ui/status/powerProfiles.js126
-rw-r--r--js/ui/status/remoteAccess.js230
-rw-r--r--js/ui/status/rfkill.js136
-rw-r--r--js/ui/status/system.js348
-rw-r--r--js/ui/status/thunderbolt.js332
-rw-r--r--js/ui/status/volume.js458
16 files changed, 5866 insertions, 0 deletions
diff --git a/js/ui/status/accessibility.js b/js/ui/status/accessibility.js
new file mode 100644
index 0000000..a4bad14
--- /dev/null
+++ b/js/ui/status/accessibility.js
@@ -0,0 +1,153 @@
+// -*- 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_TEXT_SCALING_FACTOR = 'text-scaling-factor';
+
+const A11Y_INTERFACE_SCHEMA = 'org.gnome.desktop.a11y.interface';
+const KEY_HIGH_CONTRAST = 'high-contrast';
+
+var ATIndicator = GObject.registerClass(
+class ATIndicator extends PanelMenu.Button {
+ _init() {
+ super._init(0.5, _("Accessibility"));
+
+ this.add_child(new St.Icon({
+ style_class: 'system-status-icon',
+ icon_name: 'org.gnome.Settings-accessibility-symbolic',
+ }));
+
+ this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA });
+ this._a11ySettings.connect(`changed::${KEY_ALWAYS_SHOW}`, this._queueSyncMenuVisibility.bind(this));
+
+ let highContrast = this._buildItem(_('High Contrast'), A11Y_INTERFACE_SCHEMA, KEY_HIGH_CONTRAST);
+ 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::${key}`, () => {
+ widget.setToggleState(settings.get_boolean(key));
+
+ this._queueSyncMenuVisibility();
+ });
+
+ return widget;
+ }
+
+ _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::${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/autoRotate.js b/js/ui/status/autoRotate.js
new file mode 100644
index 0000000..bde3b80
--- /dev/null
+++ b/js/ui/status/autoRotate.js
@@ -0,0 +1,45 @@
+/* exported Indicator */
+const {Gio, GObject} = imports.gi;
+
+const SystemActions = imports.misc.systemActions;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const RotationToggle = GObject.registerClass(
+class RotationToggle extends QuickToggle {
+ _init() {
+ this._systemActions = new SystemActions.getDefault();
+
+ super._init({
+ label: _('Auto Rotate'),
+ });
+
+ this._systemActions.bind_property('can-lock-orientation',
+ this, 'visible',
+ GObject.BindingFlags.DEFAULT |
+ GObject.BindingFlags.SYNC_CREATE);
+ this._systemActions.bind_property('orientation-lock-icon',
+ this, 'icon-name',
+ GObject.BindingFlags.DEFAULT |
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.settings-daemon.peripherals.touchscreen',
+ });
+ this._settings.bind('orientation-lock',
+ this, 'checked',
+ Gio.SettingsBindFlags.INVERT_BOOLEAN);
+
+ this.connect('clicked',
+ () => this._systemActions.activateLockOrientation());
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new RotationToggle());
+ }
+});
diff --git a/js/ui/status/bluetooth.js b/js/ui/status/bluetooth.js
new file mode 100644
index 0000000..bbff62d
--- /dev/null
+++ b/js/ui/status/bluetooth.js
@@ -0,0 +1,211 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GLib, GnomeBluetooth, GObject} = imports.gi;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const {loadInterfaceXML} = imports.misc.fileUtils;
+
+const {AdapterState} = GnomeBluetooth;
+
+const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill';
+const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill';
+
+const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill');
+const rfkillManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(RfkillManagerInterface);
+
+const BtClient = GObject.registerClass({
+ Properties: {
+ 'available': GObject.ParamSpec.boolean('available', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'active': GObject.ParamSpec.boolean('active', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'adapter-state': GObject.ParamSpec.enum('adapter-state', '', '',
+ GObject.ParamFlags.READABLE,
+ AdapterState, AdapterState.ABSENT),
+ },
+ Signals: {
+ 'devices-changed': {},
+ },
+}, class BtClient extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._client = new GnomeBluetooth.Client();
+ this._client.connect('notify::default-adapter-powered', () => {
+ this.notify('active');
+ this.notify('available');
+ });
+ this._client.connect('notify::default-adapter-state',
+ () => this.notify('adapter-state'));
+ this._client.connect('notify::default-adapter', () => {
+ const newAdapter = this._client.default_adapter ?? null;
+
+ this._adapter = newAdapter;
+ this._deviceNotifyConnected.clear();
+ this.emit('devices-changed');
+
+ this.notify('active');
+ this.notify('available');
+ });
+
+ this._proxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: BUS_NAME,
+ g_object_path: OBJECT_PATH,
+ g_interface_name: rfkillManagerInfo.name,
+ g_interface_info: rfkillManagerInfo,
+ });
+ this._proxy.connect('g-properties-changed', (p, properties) => {
+ const changedProperties = properties.unpack();
+ if ('BluetoothHardwareAirplaneMode' in changedProperties)
+ this.notify('available');
+ else if ('BluetoothHasAirplaneMode' in changedProperties)
+ this.notify('available');
+ });
+ this._proxy.init_async(GLib.PRIORITY_DEFAULT, null)
+ .catch(e => console.error(e.message));
+
+ this._adapter = null;
+
+ this._deviceNotifyConnected = new Set();
+
+ const deviceStore = this._client.get_devices();
+ for (let i = 0; i < deviceStore.get_n_items(); i++)
+ this._connectDeviceNotify(deviceStore.get_item(i));
+
+ this._client.connect('device-removed', (c, path) => {
+ this._deviceNotifyConnected.delete(path);
+ this.emit('devices-changed');
+ });
+ this._client.connect('device-added', (c, device) => {
+ this._connectDeviceNotify(device);
+ this.emit('devices-changed');
+ });
+ }
+
+ get available() {
+ // If we have an rfkill switch, make sure it's not a hardware
+ // one as we can't get out of it in software
+ return this._proxy.BluetoothHasAirplaneMode
+ ? !this._proxy.BluetoothHardwareAirplaneMode
+ : this.active;
+ }
+
+ get active() {
+ return this._client.default_adapter_powered;
+ }
+
+ get adapter_state() {
+ return this._client.default_adapter_state;
+ }
+
+ toggleActive() {
+ this._proxy.BluetoothAirplaneMode = this.active;
+ if (!this._client.default_adapter_powered)
+ this._client.default_adapter_powered = true;
+ }
+
+ *getDevices() {
+ const deviceStore = this._client.get_devices();
+
+ for (let i = 0; i < deviceStore.get_n_items(); i++) {
+ const device = deviceStore.get_item(i);
+
+ if (device.paired || device.trusted)
+ yield device;
+ }
+ }
+
+ _queueDevicesChanged() {
+ if (this._devicesChangedId)
+ return;
+ this._devicesChangedId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ delete this._devicesChangedId;
+ this.emit('devices-changed');
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _connectDeviceNotify(device) {
+ const path = device.get_object_path();
+
+ if (this._deviceNotifyConnected.has(path))
+ return;
+
+ device.connect('notify::alias', () => this._queueDevicesChanged());
+ device.connect('notify::paired', () => this._queueDevicesChanged());
+ device.connect('notify::trusted', () => this._queueDevicesChanged());
+ device.connect('notify::connected', () => this._queueDevicesChanged());
+
+ this._deviceNotifyConnected.add(path);
+ }
+});
+
+const BluetoothToggle = GObject.registerClass(
+class BluetoothToggle extends QuickToggle {
+ _init(client) {
+ super._init({label: _('Bluetooth')});
+
+ this._client = client;
+
+ this._client.bind_property('available',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._client.bind_property('active',
+ this, 'checked',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._client.bind_property_full('adapter-state',
+ this, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE,
+ (bind, source) => [true, this._getIconNameFromState(source)],
+ null);
+
+ this.connect('clicked', () => this._client.toggleActive());
+ }
+
+ _getIconNameFromState(state) {
+ switch (state) {
+ case AdapterState.ON:
+ return 'bluetooth-active-symbolic';
+ case AdapterState.OFF:
+ case AdapterState.ABSENT:
+ return 'bluetooth-disabled-symbolic';
+ case AdapterState.TURNING_ON:
+ case AdapterState.TURNING_OFF:
+ return 'bluetooth-acquiring-symbolic';
+ default:
+ console.warn(`Unexpected state ${
+ GObject.enum_to_string(AdapterState, state)}`);
+ return '';
+ }
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._client = new BtClient();
+ this._client.connect('devices-changed', () => this._sync());
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'bluetooth-active-symbolic';
+
+ this.quickSettingsItems.push(new BluetoothToggle(this._client));
+
+ this._sync();
+ }
+
+ _sync() {
+ const devices = [...this._client.getDevices()];
+ const connectedDevices = devices.filter(dev => dev.connected);
+ const nConnectedDevices = connectedDevices.length;
+
+ this._indicator.visible = nConnectedDevices > 0;
+ }
+});
diff --git a/js/ui/status/brightness.js b/js/ui/status/brightness.js
new file mode 100644
index 0000000..4c0da67
--- /dev/null
+++ b/js/ui/status/brightness.js
@@ -0,0 +1,64 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GObject} = imports.gi;
+
+const {QuickSlider, SystemIndicator} = imports.ui.quickSettings;
+
+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);
+
+const BrightnessItem = GObject.registerClass(
+class BrightnessItem extends QuickSlider {
+ _init() {
+ super._init({
+ iconName: 'display-brightness-symbolic',
+ });
+
+ this._proxy = new BrightnessProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error)
+ console.error(error.message);
+ else
+ this._proxy.connect('g-properties-changed', () => this._sync());
+ this._sync();
+ });
+
+ this._sliderChangedId = this.slider.connect('notify::value',
+ this._sliderChanged.bind(this));
+ this.slider.accessible_name = _('Brightness');
+ }
+
+ _sliderChanged() {
+ const 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() {
+ const brightness = this._proxy.Brightness;
+ const visible = Number.isInteger(brightness) && brightness >= 0;
+ this.visible = visible;
+ if (visible)
+ this._changeSlider(this._proxy.Brightness / 100.0);
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new BrightnessItem());
+ }
+});
diff --git a/js/ui/status/darkMode.js b/js/ui/status/darkMode.js
new file mode 100644
index 0000000..d1ec2bd
--- /dev/null
+++ b/js/ui/status/darkMode.js
@@ -0,0 +1,49 @@
+/* exported Indicator */
+const {Gio, GObject} = imports.gi;
+
+const Main = imports.ui.main;
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const DarkModeToggle = GObject.registerClass(
+class DarkModeToggle extends QuickToggle {
+ _init() {
+ super._init({
+ label: _('Dark Mode'),
+ iconName: 'dark-mode-symbolic',
+ });
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.interface',
+ });
+ this._changedId = this._settings.connect('changed::color-scheme',
+ () => this._sync());
+
+ this.connectObject(
+ 'destroy', () => this._settings.run_dispose(),
+ 'clicked', () => this._toggleMode(),
+ this);
+ this._sync();
+ }
+
+ _toggleMode() {
+ Main.layoutManager.screenTransition.run();
+ this._settings.set_string('color-scheme',
+ this.checked ? 'default' : 'prefer-dark');
+ }
+
+ _sync() {
+ const colorScheme = this._settings.get_string('color-scheme');
+ const checked = colorScheme === 'prefer-dark';
+ if (this.checked !== checked)
+ this.set({checked});
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new DarkModeToggle());
+ }
+});
diff --git a/js/ui/status/dwellClick.js b/js/ui/status/dwellClick.js
new file mode 100644
index 0000000..82726e5
--- /dev/null
+++ b/js/ui/status/dwellClick.js
@@ -0,0 +1,83 @@
+/* exported DwellClickIndicator */
+const { Clutter, Gio, GLib, GObject, St } = imports.gi;
+
+const PanelMenu = imports.ui.panelMenu;
+
+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._icon = new St.Icon({
+ style_class: 'system-status-icon',
+ icon_name: 'pointer-primary-click-symbolic',
+ });
+ this.add_child(this._icon);
+
+ this._a11ySettings = new Gio.Settings({ schema_id: MOUSE_A11Y_SCHEMA });
+ this._a11ySettings.connect(`changed::${KEY_DWELL_CLICK_ENABLED}`, this._syncMenuVisibility.bind(this));
+ this._a11ySettings.connect(`changed::${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..b47375d
--- /dev/null
+++ b/js/ui/status/keyboard.js
@@ -0,0 +1,1095 @@
+// -*- 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.misc.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 extends Signals.EventEmitter {
+ constructor(type, id, displayName, shortName, index) {
+ super();
+
+ 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 `${engineDesc.layout}+${engineDesc.variant}`;
+ else
+ return engineDesc.layout;
+ }
+};
+
+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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ if (this.constructor === InputSourceSettings)
+ throw new TypeError(`Cannot instantiate abstract class ${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;
+ }
+};
+
+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.deepUnpack();
+ } catch (e) {
+ log(`Could not get properties from ${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 += `+${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::${this._KEY_INPUT_SOURCES}`, this._emitInputSourcesChanged.bind(this));
+ this._settings.connect(`changed::${this._KEY_KEYBOARD_OPTIONS}`, this._emitKeyboardOptionsChanged.bind(this));
+ this._settings.connect(`changed::${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).deepUnpack();
+ 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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ // 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._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;
+ }
+
+ this._switcherPopup = new InputSourcePopup(
+ this._mruSources, this._keybindingAction, this._keybindingActionBackward);
+ this._switcherPopup.connect('destroy', () => {
+ this._switcherPopup = null;
+ });
+ if (!this._switcherPopup.show(
+ binding.is_reversed(), binding.get_name(), binding.get_mask()))
+ this._switcherPopup.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 of Object.keys(this._inputSources).sort((a, b) => a - b))
+ 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 = [];
+ if (this._mruSources.length > 1) {
+ 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 = `${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) {
+ // Avoid purpose changes while the switcher popup is shown, likely due to
+ // the focus change caused by the switcher popup causing this purpose change.
+ if (this._switcherPopup)
+ return;
+ 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));
+ Main.overview.connectObject(
+ 'showing', this._setPerWindowInputSource.bind(this),
+ 'hidden', this._setPerWindowInputSource.bind(this), this);
+ } else if (!this._sourcesPerWindow && this._focusWindowNotifyId != 0) {
+ global.display.disconnect(this._focusWindowNotifyId);
+ this._focusWindowNotifyId = 0;
+ Main.overview.disconnectObject(this);
+
+ 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;
+ }
+
+ get keyboardManager() {
+ return this._keyboardManager;
+ }
+};
+
+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({ style_class: 'system-status-icon' });
+ this.add_child(this._container);
+
+ 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._inputSourceManager.connectObject(
+ 'sources-changed', this._sourcesChanged.bind(this),
+ 'current-source-changed', this._currentSourceChanged.bind(this), this);
+ this._inputSourceManager.reload();
+ }
+
+ _onDestroy() {
+ 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));
+
+ const 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 ${prop.get_key()} has invalid type ${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();
+ }
+
+ // The `default` layout from ibus engine means to
+ // use the current keyboard layout.
+ if (xkbLayout === 'default') {
+ const current = this._inputSourceManager.keyboardManager.currentLayout;
+ xkbLayout = current.layout;
+ xkbVariant = current.variant;
+ }
+ }
+
+ if (!xkbLayout || xkbLayout.length == 0)
+ return;
+
+ let description = xkbLayout;
+ if (xkbVariant.length > 0)
+ description = `${description}\t${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..45f6b7a
--- /dev/null
+++ b/js/ui/status/location.js
@@ -0,0 +1,371 @@
+// -*- 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 ModalDialog = imports.ui.modalDialog;
+const PermissionStore = imports.misc.permissionStore;
+const {SystemIndicator} = imports.ui.quickSettings;
+
+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 = 'location';
+const APP_PERMISSIONS_ID = 'location';
+
+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');
+
+let _geoclueAgent = null;
+function _getGeoclueAgent() {
+ if (_geoclueAgent === null)
+ _geoclueAgent = new GeoclueAgent();
+ return _geoclueAgent;
+}
+
+var GeoclueAgent = GObject.registerClass({
+ Properties: {
+ 'enabled': GObject.ParamSpec.boolean(
+ 'enabled', 'Enabled', 'Enabled',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'in-use': GObject.ParamSpec.boolean(
+ 'in-use', 'In use', 'In use',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'max-accuracy-level': GObject.ParamSpec.int(
+ 'max-accuracy-level', 'Max accuracy level', 'Max accuracy level',
+ GObject.ParamFlags.READABLE,
+ 0, 8, 0),
+ },
+}, class GeoclueAgent extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._settings = new Gio.Settings({ schema_id: LOCATION_SCHEMA });
+ this._settings.connectObject(
+ `changed::${ENABLED}`, () => this.notify('enabled'),
+ `changed::${MAX_ACCURACY_LEVEL}`, () => this._onMaxAccuracyLevelChanged(),
+ this);
+
+ this._agent = Gio.DBusExportedObject.wrapJSObject(AgentIface, this);
+ this._agent.export(Gio.DBus.system, '/org/freedesktop/GeoClue2/Agent');
+
+ this.connect('notify::enabled', this._onMaxAccuracyLevelChanged.bind(this));
+
+ this._watchId = Gio.bus_watch_name(Gio.BusType.SYSTEM,
+ 'org.freedesktop.GeoClue2',
+ 0,
+ this._connectToGeoclue.bind(this),
+ this._onGeoclueVanished.bind(this));
+ this._onMaxAccuracyLevelChanged();
+ this._connectToGeoclue();
+ this._connectToPermissionStore();
+ }
+
+ get enabled() {
+ return this._settings.get_boolean(ENABLED);
+ }
+
+ set enabled(value) {
+ this._settings.set_boolean(ENABLED, value);
+ }
+
+ get inUse() {
+ return this._managerProxy?.InUse ?? false;
+ }
+
+ get maxAccuracyLevel() {
+ if (this.enabled) {
+ let level = this._settings.get_string(MAX_ACCURACY_LEVEL);
+
+ return GeoclueAccuracyLevel[level.toUpperCase()] ||
+ GeoclueAccuracyLevel.NONE;
+ } else {
+ return GeoclueAccuracyLevel.NONE;
+ }
+ }
+
+ async AuthorizeAppAsync(params, invocation) {
+ let [desktopId, reqAccuracyLevel] = params;
+
+ let authorizer = new AppAuthorizer(desktopId,
+ reqAccuracyLevel, this._permStoreProxy, this.maxAccuracyLevel);
+
+ const accuracyLevel = await authorizer.authorize();
+ const ret = accuracyLevel !== GeoclueAccuracyLevel.NONE;
+ invocation.return_value(GLib.Variant.new('(bu)', [ret, accuracyLevel]));
+ }
+
+ get MaxAccuracyLevel() {
+ return this.maxAccuracyLevel;
+ }
+
+ _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;
+ }
+
+ async _onManagerProxyReady(proxy, error) {
+ if (error != null) {
+ log(error.message);
+ this._connecting = false;
+ return;
+ }
+
+ this._managerProxy = proxy;
+ this._managerProxy.connectObject('g-properties-changed',
+ this._onGeocluePropsChanged.bind(this), this);
+
+ this.notify('in-use');
+
+ try {
+ await this._managerProxy.AddAgentAsync('gnome-shell');
+ this._connecting = false;
+ this._notifyMaxAccuracyLevel();
+ } catch (e) {
+ log(e.message);
+ }
+ }
+
+ _onGeoclueVanished() {
+ this._managerProxy?.disconnectObject(this);
+ this._managerProxy = null;
+
+ this.notify('in-use');
+ }
+
+ _onMaxAccuracyLevelChanged() {
+ this.notify('max-accuracy-level');
+
+ // 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();
+ }
+
+ _notifyMaxAccuracyLevel() {
+ let variant = new GLib.Variant('u', this.maxAccuracyLevel);
+ this._agent.emit_property_changed('MaxAccuracyLevel', variant);
+ }
+
+ _onGeocluePropsChanged(proxy, properties) {
+ const inUseChanged = !!properties.lookup_value('InUse', null);
+ if (inUseChanged)
+ this.notify('in-use');
+ }
+
+ _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 Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._agent = _getGeoclueAgent();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'location-services-active-symbolic';
+ this._agent.bind_property('in-use',
+ this._indicator,
+ 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+});
+
+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;
+ }
+
+ async authorize() {
+ let appSystem = Shell.AppSystem.get_default();
+ this._app = appSystem.lookup_app(`${this.desktopId}.desktop`);
+ if (this._app == null || this._permStoreProxy == null)
+ return this._completeAuth();
+
+ try {
+ [this._permissions] = await this._permStoreProxy.LookupAsync(
+ APP_PERMISSIONS_TABLE,
+ APP_PERMISSIONS_ID);
+ } catch (error) {
+ if (error.domain === Gio.DBusError) {
+ // Likely no xdg-app installed, just authorize the app
+ this._accuracyLevel = this.reqAccuracyLevel;
+ this._permStoreProxy = null;
+ return 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 = {};
+ }
+ }
+
+ let permission = this._permissions[this.desktopId];
+
+ if (permission == null) {
+ await this._userAuthorizeApp();
+ } else {
+ let [levelStr] = permission || ['NONE'];
+ this._accuracyLevel = GeoclueAccuracyLevel[levelStr] ||
+ GeoclueAccuracyLevel.NONE;
+ }
+
+ return 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._dialog =
+ new GeolocationDialog(name, reason, this.reqAccuracyLevel);
+
+ return new Promise(resolve => {
+ const responseId = this._dialog.connect('response',
+ (dialog, level) => {
+ this._dialog.disconnect(responseId);
+ this._accuracyLevel = level;
+ resolve();
+ });
+ this._dialog.open();
+ });
+ }
+
+ _completeAuth() {
+ if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) {
+ this._accuracyLevel = Math.clamp(this._accuracyLevel,
+ 0, this._maxAccuracyLevel);
+ }
+ this._saveToPermissionStore();
+
+ return this._accuracyLevel;
+ }
+
+ async _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', {});
+
+ try {
+ await this._permStoreProxy.SetAsync(
+ APP_PERMISSIONS_TABLE,
+ true,
+ APP_PERMISSIONS_ID,
+ this._permissions,
+ data);
+ } catch (error) {
+ 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);
+
+ const 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..d9755a3
--- /dev/null
+++ b/js/ui/status/network.js
@@ -0,0 +1,2095 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+const {Atk, Clutter, Gio, GLib, GObject, NM, Polkit, St} = imports.gi;
+
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const MessageTray = imports.ui.messageTray;
+const ModemManager = imports.misc.modemManager;
+const Util = imports.misc.util;
+
+const {Spinner} = imports.ui.animation;
+const {QuickMenuToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const {loadInterfaceXML} = imports.misc.fileUtils;
+const {registerDestroyableType} = imports.misc.signalTracker;
+
+Gio._promisify(Gio.DBusConnection.prototype, 'call');
+Gio._promisify(NM.Client, 'new_async');
+Gio._promisify(NM.Client.prototype, 'check_connectivity_async');
+Gio._promisify(NM.DeviceWifi.prototype, 'request_scan_async');
+
+const WIFI_SCAN_FREQUENCY = 15;
+const MAX_VISIBLE_NETWORKS = 8;
+
+// small optimization, to avoid using [] all the time
+const NM80211Mode = NM['80211Mode'];
+
+var PortalHelperResult = {
+ CANCELLED: 0,
+ COMPLETED: 1,
+ RECHECK: 2,
+};
+
+const PortalHelperIface = loadInterfaceXML('org.gnome.Shell.PortalHelper');
+const PortalHelperInfo = Gio.DBusInterfaceInfo.new_for_xml(PortalHelperIface);
+
+function signalToIcon(value) {
+ if (value < 20)
+ return 'none';
+ else if (value < 40)
+ return 'weak';
+ else if (value < 50)
+ return 'ok';
+ else if (value < 80)
+ return 'good';
+ else
+ return 'excellent';
+}
+
+function ssidToLabel(ssid) {
+ let label = NM.utils_ssid_to_utf8(ssid.get_data());
+ if (!label)
+ label = _("<unknown>");
+ return label;
+}
+
+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${global.get_current_time()}`),
+ };
+ try {
+ Gio.DBus.session.call(
+ 'org.gnome.Settings',
+ '/org/gnome/Settings',
+ '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: ${e.message}`);
+ }
+}
+
+class ItemSorter {
+ [Symbol.iterator] = this.items;
+
+ /**
+ * Maintains a list of sorted items. By default, items are
+ * assumed to be objects with a name property.
+ *
+ * Optionally items can have a secondary sort order by
+ * recency. If used, items must by objects with a timestamp
+ * property that can be used in substraction, and "bigger"
+ * must mean "more recent". Number and Date both qualify.
+ *
+ * @param {object=} options - property object with options
+ * @param {Function} options.sortFunc - a custom sort function
+ * @param {bool} options.trackMru - whether to track MRU order as well
+ **/
+ constructor(options = {}) {
+ const {sortFunc, trackMru} = {
+ sortFunc: this._sortByName.bind(this),
+ trackMru: false,
+ ...options,
+ };
+
+ this._trackMru = trackMru;
+ this._sortFunc = sortFunc;
+ this._sortFuncMru = this._sortByMru.bind(this);
+
+ this._itemsOrder = [];
+ this._itemsMruOrder = [];
+ }
+
+ *items() {
+ yield* this._itemsOrder;
+ }
+
+ *itemsByMru() {
+ console.assert(this._trackMru, 'itemsByMru: MRU tracking is disabled');
+ yield* this._itemsMruOrder;
+ }
+
+ _sortByName(one, two) {
+ return GLib.utf8_collate(one.name, two.name);
+ }
+
+ _sortByMru(one, two) {
+ return two.timestamp - one.timestamp;
+ }
+
+ _upsert(array, item, sortFunc) {
+ this._delete(array, item);
+ return Util.insertSorted(array, item, sortFunc);
+ }
+
+ _delete(array, item) {
+ const pos = array.indexOf(item);
+ if (pos >= 0)
+ array.splice(pos, 1);
+ }
+
+ /**
+ * Insert or update item.
+ *
+ * @param {any} item - the item to upsert
+ * @returns {number} - the sorted position of item
+ */
+ upsert(item) {
+ if (this._trackMru)
+ this._upsert(this._itemsMruOrder, item, this._sortFuncMru);
+
+ return this._upsert(this._itemsOrder, item, this._sortFunc);
+ }
+
+ /**
+ * @param {any} item - item to remove
+ */
+ delete(item) {
+ if (this._trackMru)
+ this._delete(this._itemsMruOrder, item);
+ this._delete(this._itemsOrder, item);
+ }
+}
+
+const NMMenuItem = GObject.registerClass({
+ Properties: {
+ 'radio-mode': GObject.ParamSpec.boolean('radio-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'is-active': GObject.ParamSpec.boolean('is-active', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'name': GObject.ParamSpec.string('name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ 'icon-name': GObject.ParamSpec.string('icon-name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ },
+}, class NMMenuItem extends PopupMenu.PopupBaseMenuItem {
+ get state() {
+ return this._activeConnection?.state ??
+ NM.ActiveConnectionState.DEACTIVATED;
+ }
+
+ get is_active() {
+ return this.state <= NM.ActiveConnectionState.ACTIVATED;
+ }
+
+ get timestamp() {
+ return 0;
+ }
+
+ activate() {
+ super.activate(Clutter.get_current_event());
+ }
+
+ _activeConnectionStateChanged() {
+ this.notify('is-active');
+ this.notify('icon-name');
+
+ this._sync();
+ }
+
+ _setActiveConnection(activeConnection) {
+ this._activeConnection?.disconnectObject(this);
+
+ this._activeConnection = activeConnection;
+
+ this._activeConnection?.connectObject(
+ 'notify::state', () => this._activeConnectionStateChanged(),
+ this);
+ this._activeConnectionStateChanged();
+ }
+
+ _sync() {
+ // Overridden by subclasses
+ }
+});
+
+/**
+ * Item that contains a section, and can be collapsed
+ * into a submenu
+ */
+const NMSectionItem = GObject.registerClass({
+ Properties: {
+ 'use-submenu': GObject.ParamSpec.boolean('use-submenu', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class NMSectionItem extends NMMenuItem {
+ constructor() {
+ super({
+ activate: false,
+ can_focus: false,
+ });
+
+ this._useSubmenu = false;
+
+ // Turn into an empty container with no padding
+ this.styleClass = '';
+ this.setOrnament(PopupMenu.Ornament.HIDDEN);
+
+ // Add intermediate section; we need this for submenu support
+ this._mainSection = new PopupMenu.PopupMenuSection();
+ this.add_child(this._mainSection.actor);
+
+ this._submenuItem = new PopupMenu.PopupSubMenuMenuItem('', true);
+ this._mainSection.addMenuItem(this._submenuItem);
+ this._submenuItem.hide();
+
+ this.section = new PopupMenu.PopupMenuSection();
+ this._mainSection.addMenuItem(this.section);
+
+ // Represents the item as a whole when shown
+ this.bind_property('name',
+ this._submenuItem.label, 'text',
+ GObject.BindingFlags.DEFAULT);
+ this.bind_property('icon-name',
+ this._submenuItem.icon, 'icon-name',
+ GObject.BindingFlags.DEFAULT);
+ }
+
+ _setParent(parent) {
+ super._setParent(parent);
+ this._mainSection._setParent(parent);
+
+ parent?.connect('menu-closed',
+ () => this._mainSection.emit('menu-closed'));
+ }
+
+ get use_submenu() {
+ return this._useSubmenu;
+ }
+
+ set use_submenu(useSubmenu) {
+ if (this._useSubmenu === useSubmenu)
+ return;
+
+ this._useSubmenu = useSubmenu;
+ this._submenuItem.visible = useSubmenu;
+
+ if (useSubmenu) {
+ this._mainSection.box.remove_child(this.section.actor);
+ this._submenuItem.menu.box.add_child(this.section.actor);
+ } else {
+ this._submenuItem.menu.box.remove_child(this.section.actor);
+ this._mainSection.box.add_child(this.section.actor);
+ }
+ }
+});
+
+const NMConnectionItem = GObject.registerClass(
+class NMConnectionItem extends NMMenuItem {
+ constructor(section, connection) {
+ super();
+
+ this._section = section;
+ this._connection = connection;
+ this._activeConnection = null;
+
+ this._icon = new St.Icon({
+ style_class: 'popup-menu-icon',
+ x_align: Clutter.ActorAlign.END,
+ visible: !this.radio_mode,
+ });
+ this.add_child(this._icon);
+
+ this._label = new St.Label({
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this.add_child(this._label);
+ this.label_actor = this._label;
+
+ this.bind_property('icon-name',
+ this._icon, 'icon-name',
+ GObject.BindingFlags.DEFAULT);
+ this.bind_property('radio-mode',
+ this._icon, 'visible',
+ GObject.BindingFlags.INVERT_BOOLEAN);
+
+ this.connectObject(
+ 'notify::radio-mode', () => this._sync(),
+ 'notify::name', () => this._sync(),
+ this);
+ this._sync();
+ }
+
+ get name() {
+ return this._connection.get_id();
+ }
+
+ get timestamp() {
+ return this._connection.get_setting_connection()?.get_timestamp() ?? 0;
+ }
+
+ 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.notify('name');
+ this._sync();
+ }
+
+ _updateOrnament() {
+ this.setOrnament(this.radio_mode && this.is_active
+ ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE);
+ }
+
+ _getRegularLabel() {
+ return this.is_active
+ // Translators: %s is a device name like "MyPhone"
+ ? _('Disconnect %s').format(this.name)
+ // Translators: %s is a device name like "MyPhone"
+ : _('Connect to %s').format(this.name);
+ }
+
+ _sync() {
+ if (this.radioMode) {
+ this._label.text = this.name;
+ this.accessible_role = Atk.Role.CHECK_MENU_ITEM;
+ } else {
+ this._label.text = this._getRegularLabel();
+ this.accessible_role = Atk.Role.MENU_ITEM;
+ }
+ this._updateOrnament();
+ }
+
+ activate() {
+ super.activate();
+
+ if (this.radio_mode && this._activeConnection != null)
+ return; // only activate in radio mode
+
+ if (this._activeConnection == null)
+ this._section.activateConnection(this._connection);
+ else
+ this._section.deactivateConnection(this._activeConnection);
+
+ this._sync();
+ }
+
+ setActiveConnection(connection) {
+ this._setActiveConnection(connection);
+ }
+});
+
+const NMDeviceConnectionItem = GObject.registerClass({
+ Properties: {
+ 'device-name': GObject.ParamSpec.string('device-name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ },
+}, class NMDeviceConnectionItem extends NMConnectionItem {
+ constructor(section, connection) {
+ super(section, connection);
+
+ this.connectObject(
+ 'notify::radio-mode', () => this.notify('name'),
+ 'notify::device-name', () => this.notify('name'),
+ this);
+ }
+
+ get name() {
+ return this.radioMode
+ ? this._connection.get_id()
+ : this.deviceName;
+ }
+});
+
+const NMDeviceItem = GObject.registerClass({
+ Properties: {
+ 'single-device-mode': GObject.ParamSpec.boolean('single-device-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class NMDeviceItem extends NMSectionItem {
+ constructor(client, device) {
+ super();
+
+ if (this.constructor === NMDeviceItem)
+ throw new TypeError(`Cannot instantiate abstract type ${this.constructor.name}`);
+
+ this._client = client;
+ this._device = device;
+ this._deviceName = '';
+
+ this._connectionItems = new Map();
+ this._itemSorter = new ItemSorter({trackMru: true});
+
+ // Item shown in the 0-connections case
+ this._autoConnectItem =
+ this.section.addAction(_('Connect'), () => this._autoConnect(), '');
+
+ // Represents the device as a whole when shown
+ this.bind_property('name',
+ this._autoConnectItem.label, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('icon-name',
+ this._autoConnectItem._icon, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._deactivateItem =
+ this.section.addAction(_('Turn Off'), () => this.deactivateConnection());
+
+ this._client.connectObject(
+ 'notify::connectivity', () => this.notify('icon-name'),
+ 'notify::primary-connection', () => this.notify('icon-name'),
+ this);
+
+ this._device.connectObject(
+ 'notify::available-connections', () => this._syncConnections(),
+ 'notify::active-connection', () => this._activeConnectionChanged(),
+ this);
+
+ this.connect('notify::single-device-mode', () => this._sync());
+
+ this._syncConnections();
+ this._activeConnectionChanged();
+ }
+
+ get timestamp() {
+ const [item] = this._itemSorter.itemsByMru();
+ return item?.timestamp ?? 0;
+ }
+
+ _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);
+ }
+
+ _activeConnectionChanged() {
+ const oldItem = this._connectionItems.get(
+ this._activeConnection?.connection);
+ oldItem?.setActiveConnection(null);
+
+ this._setActiveConnection(this._device.active_connection);
+
+ const newItem = this._connectionItems.get(
+ this._activeConnection?.connection);
+ newItem?.setActiveConnection(this._activeConnection);
+ }
+
+ _syncConnections() {
+ const available = this._device.get_available_connections();
+ const removed = [...this._connectionItems.keys()]
+ .filter(conn => !available.includes(conn));
+
+ for (const conn of removed)
+ this._removeConnection(conn);
+
+ for (const conn of available)
+ this._addConnection(conn);
+ }
+
+ _getActivatableItem() {
+ const [lastUsed] = this._itemSorter.itemsByMru();
+ if (lastUsed?.timestamp > 0)
+ return lastUsed;
+
+ const [firstItem] = this._itemSorter;
+ if (firstItem)
+ return firstItem;
+
+ console.assert(this._autoConnectItem.visible,
+ `${this}'s autoConnect item should be visible when otherwise empty`);
+ return this._autoConnectItem;
+ }
+
+ activate() {
+ super.activate();
+
+ if (this._activeConnection)
+ this.deactivateConnection();
+ else
+ this._getActivatableItem()?.activate();
+ }
+
+ activateConnection(connection) {
+ this._client.activate_connection_async(connection, this._device, null, null, null);
+ }
+
+ deactivateConnection(_activeConnection) {
+ this._device.disconnect(null);
+ }
+
+ _onConnectionChanged(connection) {
+ const item = this._connectionItems.get(connection);
+ item.updateForConnection(connection);
+ }
+
+ _resortItem(item) {
+ const pos = this._itemSorter.upsert(item);
+ this.section.moveMenuItem(item, pos);
+ }
+
+ _addConnection(connection) {
+ if (this._connectionItems.has(connection))
+ return;
+
+ connection.connectObject(
+ 'changed', this._onConnectionChanged.bind(this),
+ this);
+
+ const item = new NMDeviceConnectionItem(this, connection);
+
+ this.bind_property('radio-mode',
+ item, 'radio-mode',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('name',
+ item, 'device-name',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('icon-name',
+ item, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+ item.connectObject(
+ 'notify::name', () => this._resortItem(item),
+ this);
+
+ const pos = this._itemSorter.upsert(item);
+ this.section.addMenuItem(item, pos);
+ this._connectionItems.set(connection, item);
+ this._sync();
+ }
+
+ _removeConnection(connection) {
+ const item = this._connectionItems.get(connection);
+ if (!item)
+ return;
+
+ this._itemSorter.delete(item);
+ this._connectionItems.delete(connection);
+ item.destroy();
+
+ this._sync();
+ }
+
+ setDeviceName(name) {
+ this._deviceName = name;
+ this.notify('name');
+ }
+
+ _sync() {
+ const nItems = this._connectionItems.size;
+ this.radio_mode = nItems > 1;
+ this.useSubmenu = this.radioMode && !this.singleDeviceMode;
+ this._autoConnectItem.visible = nItems === 0;
+ this._deactivateItem.visible = this.radioMode && this.isActive;
+ }
+});
+
+const NMWiredDeviceItem = GObject.registerClass(
+class NMWiredDeviceItem extends NMDeviceItem {
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-wired-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED:
+ return this._canReachInternet()
+ ? 'network-wired-symbolic'
+ : 'network-wired-no-route-symbolic';
+ default:
+ return 'network-wired-disconnected-symbolic';
+ }
+ }
+
+ get name() {
+ return this._deviceName;
+ }
+
+ _hasCarrier() {
+ if (this._device instanceof NM.DeviceEthernet)
+ return this._device.carrier;
+ else
+ return true;
+ }
+
+ _sync() {
+ this.visible = this._hasCarrier();
+ super._sync();
+ }
+});
+
+const NMModemDeviceItem = GObject.registerClass(
+class NMModemDeviceItem extends NMDeviceItem {
+ constructor(client, device) {
+ super(client, device);
+
+ 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);
+
+ this._mobileDevice?.connectObject(
+ 'notify::operator-name', this._sync.bind(this),
+ 'notify::signal-quality', () => this.notify('icon-name'), this);
+
+ Main.sessionMode.connectObject('updated',
+ this._sessionUpdated.bind(this), this);
+ this._sessionUpdated();
+ }
+
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-cellular-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED: {
+ const qualityString = signalToIcon(this._mobileDevice.signal_quality);
+ return `network-cellular-signal-${qualityString}-symbolic`;
+ }
+ default:
+ return this._activeConnection
+ ? 'network-cellular-signal-none-symbolic'
+ : 'network-cellular-disabled-symbolic';
+ }
+ }
+
+ get name() {
+ return this._mobileDevice?.operator_name || this._deviceName;
+ }
+
+ get wwanPanelSupported() {
+ // Currently, wwan panel doesn't support CDMA_EVDO modems
+ const supportedCaps =
+ NM.DeviceModemCapabilities.GSM_UMTS |
+ NM.DeviceModemCapabilities.LTE;
+ return this._device.current_capabilities & supportedCaps;
+ }
+
+ _autoConnect() {
+ if (this.wwanPanelSupported)
+ launchSettingsPanel('wwan', 'show-device', this._device.udi);
+ else
+ launchSettingsPanel('network', 'connect-3g', this._device.get_path());
+ }
+
+ _sessionUpdated() {
+ this._autoConnectItem.sensitive = Main.sessionMode.hasWindows;
+ }
+});
+
+const NMBluetoothDeviceItem = GObject.registerClass(
+class NMBluetoothDeviceItem extends NMDeviceItem {
+ constructor(client, device) {
+ super(client, device);
+
+ this._device.bind_property('name',
+ this, 'name',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-cellular-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED:
+ return 'network-cellular-connected-symbolic';
+ default:
+ return this._activeConnection
+ ? 'network-cellular-signal-none-symbolic'
+ : 'network-cellular-disabled-symbolic';
+ }
+ }
+
+ get name() {
+ return this._device.name;
+ }
+});
+
+const WirelessNetwork = GObject.registerClass({
+ Properties: {
+ 'name': GObject.ParamSpec.string(
+ 'name', '', '',
+ GObject.ParamFlags.READABLE,
+ ''),
+ 'icon-name': GObject.ParamSpec.string(
+ 'icon-name', '', '',
+ GObject.ParamFlags.READABLE,
+ ''),
+ 'secure': GObject.ParamSpec.boolean(
+ 'secure', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'is-active': GObject.ParamSpec.boolean(
+ 'is-active', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+ Signals: {
+ 'destroy': {},
+ },
+}, class WirelessNetwork extends GObject.Object {
+ static _securityTypes =
+ Object.values(NM.UtilsSecurityType).sort((a, b) => b - a);
+
+ _init(device) {
+ super._init();
+
+ this._device = device;
+
+ this._device.connectObject(
+ 'notify::active-access-point', () => this.notify('is-active'),
+ this);
+
+ this._accessPoints = new Set();
+ this._connections = [];
+ this._name = '';
+ this._ssid = null;
+ this._bestAp = null;
+ this._mode = 0;
+ this._securityType = NM.UtilsSecurityType.NONE;
+ }
+
+ get _strength() {
+ return this._bestAp?.strength ?? 0;
+ }
+
+ get name() {
+ return this._name;
+ }
+
+ get icon_name() {
+ if (this._mode === NM80211Mode.ADHOC)
+ return 'network-workgroup-symbolic';
+
+ if (!this._bestAp)
+ return '';
+
+ return `network-wireless-signal-${signalToIcon(this._bestAp.strength)}-symbolic`;
+ }
+
+ get secure() {
+ return this._securityType !== NM.UtilsSecurityType.NONE;
+ }
+
+ get is_active() {
+ return this._accessPoints.has(this._device.activeAccessPoint);
+ }
+
+ hasAccessPoint(ap) {
+ return this._accessPoints.has(ap);
+ }
+
+ hasAccessPoints() {
+ return this._accessPoints.size > 0;
+ }
+
+ checkAccessPoint(ap) {
+ if (!ap.get_ssid())
+ return false;
+
+ const secType = this._getApSecurityType(ap);
+ if (secType === NM.UtilsSecurityType.INVALID)
+ return false;
+
+ if (this._accessPoints.size === 0)
+ return true;
+
+ return this._ssid.equal(ap.ssid) &&
+ this._mode === ap.mode &&
+ this._securityType === secType;
+ }
+
+ /**
+ * @param {NM.AccessPoint} ap - an access point
+ * @returns {bool} - whether the access point was added
+ */
+ addAccessPoint(ap) {
+ if (!this.checkAccessPoint(ap))
+ return false;
+
+ if (this._accessPoints.size === 0) {
+ this._ssid = ap.get_ssid();
+ this._mode = ap.mode;
+ this._securityType = this._getApSecurityType(ap);
+ this._name = NM.utils_ssid_to_utf8(this._ssid.get_data()) || '<unknown>';
+
+ this.notify('name');
+ this.notify('secure');
+ }
+
+ const wasActive = this.is_active;
+ this._accessPoints.add(ap);
+
+ ap.connectObject(
+ 'notify::strength', () => {
+ this.notify('icon-name');
+ this._updateBestAp();
+ }, this);
+ this._updateBestAp();
+
+ if (wasActive !== this.is_active)
+ this.notify('is-active');
+
+ return true;
+ }
+
+ /**
+ * @param {NM.AccessPoint} ap - an access point
+ * @returns {bool} - whether the access point was removed
+ */
+ removeAccessPoint(ap) {
+ const wasActive = this.is_active;
+ if (!this._accessPoints.delete(ap))
+ return false;
+
+ ap.disconnectObject(this);
+ this._updateBestAp();
+
+ if (wasActive !== this.is_active)
+ this.notify('is-active');
+
+ return true;
+ }
+
+ /**
+ * @param {WirelessNetwork} other - network to compare with
+ * @returns {number} - the sort order
+ */
+ compare(other) {
+ // place known connections first
+ const cmpConnections = other.hasConnections() - this.hasConnections();
+ if (cmpConnections !== 0)
+ return cmpConnections;
+
+ const cmpAps = other.hasAccessPoints() - this.hasAccessPoints();
+ if (cmpAps !== 0)
+ return cmpAps;
+
+ // place stronger connections first
+ const cmpStrength = other._strength - this._strength;
+ if (cmpStrength !== 0)
+ return cmpStrength;
+
+ // place secure connections first
+ const cmpSec = other.secure - this.secure;
+ if (cmpSec !== 0)
+ return cmpSec;
+
+ // sort alphabetically
+ return GLib.utf8_collate(this._name, other._name);
+ }
+
+ hasConnections() {
+ return this._connections.length > 0;
+ }
+
+ checkConnections(connections) {
+ const aps = [...this._accessPoints];
+ this._connections = connections.filter(
+ c => aps.some(ap => ap.connection_valid(c)));
+ }
+
+ canAutoconnect() {
+ const canAutoconnect =
+ this._securityTypes !== NM.UtilsSecurityType.WPA_ENTERPRISE &&
+ this._securityTypes !== NM.UtilsSecurityType.WPA2_ENTERPRISE;
+ return canAutoconnect;
+ }
+
+ activate() {
+ const [ap] = this._accessPoints;
+ let [conn] = this._connections;
+ if (conn) {
+ this._device.client.activate_connection_async(conn, this._device, null, null, null);
+ } else if (!this.canAutoconnect()) {
+ launchSettingsPanel('wifi', 'connect-8021x-wifi',
+ this._getDeviceDBusPath(), ap.get_path());
+ } else {
+ conn = new NM.SimpleConnection();
+ this._device.client.add_and_activate_connection_async(
+ conn, this._device, ap.get_path(), null, null);
+ }
+ }
+
+ destroy() {
+ this.emit('destroy');
+ }
+
+ _getDeviceDBusPath() {
+ // nm_object_get_path() is shadowed by nm_device_get_path()
+ return NM.Object.prototype.get_path.call(this._device);
+ }
+
+ _getApSecurityType(ap) {
+ const {wirelessCapabilities: caps} = this._device;
+ const {flags, wpaFlags, rsnFlags} = ap;
+ const haveAp = true;
+ const adHoc = ap.mode === NM80211Mode.ADHOC;
+ const bestType = WirelessNetwork._securityTypes
+ .find(t => NM.utils_security_valid(t, caps, haveAp, adHoc, flags, wpaFlags, rsnFlags));
+ return bestType ?? NM.UtilsSecurityType.INVALID;
+ }
+
+ _updateBestAp() {
+ const [bestAp] =
+ [...this._accessPoints].sort((a, b) => b.strength - a.strength);
+
+ if (this._bestAp === bestAp)
+ return;
+
+ this._bestAp = bestAp;
+ this.notify('icon-name');
+ }
+});
+registerDestroyableType(WirelessNetwork);
+
+const NMWirelessNetworkItem = GObject.registerClass(
+class NMWirelessNetworkItem extends PopupMenu.PopupBaseMenuItem {
+ _init(network) {
+ super._init({style_class: 'nm-network-item'});
+
+ this._network = network;
+
+ const icons = new St.BoxLayout();
+ this.add_child(icons);
+
+ this._signalIcon = new St.Icon({style_class: 'popup-menu-icon'});
+ icons.add_child(this._signalIcon);
+
+ this._secureIcon = new St.Icon({
+ style_class: 'wireless-secure-icon',
+ y_align: Clutter.ActorAlign.END,
+ });
+ icons.add_actor(this._secureIcon);
+
+ this._label = new St.Label();
+ this.label_actor = this._label;
+ this.add_child(this._label);
+
+ this._selectedIcon = new St.Icon({
+ style_class: 'popup-menu-icon',
+ icon_name: 'object-select-symbolic',
+ });
+ this.add(this._selectedIcon);
+
+ this._network.bind_property('icon-name',
+ this._signalIcon, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._network.bind_property('name',
+ this._label, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._network.bind_property('is-active',
+ this._selectedIcon, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._network.bind_property_full('secure',
+ this._secureIcon, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE,
+ (bind, source) => [true, source ? 'network-wireless-encrypted-symbolic' : ''],
+ null);
+ }
+
+ get network() {
+ return this._network;
+ }
+});
+
+const NMWirelessDeviceItem = GObject.registerClass({
+ Properties: {
+ 'is-hotspot': GObject.ParamSpec.boolean('is-hotspot', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'single-device-mode': GObject.ParamSpec.boolean('single-device-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class NMWirelessDeviceItem extends NMSectionItem {
+ constructor(client, device) {
+ super();
+
+ this._client = client;
+ this._device = device;
+
+ this._deviceName = '';
+
+ this._networkItems = new Map();
+ this._itemSorter = new ItemSorter({
+ sortFunc: (one, two) => one.network.compare(two.network),
+ });
+
+ this._client.connectObject(
+ 'notify::wireless-enabled', () => this.notify('icon-name'),
+ 'notify::connectivity', () => this.notify('icon-name'),
+ 'notify::primary-connection', () => this.notify('icon-name'),
+ this);
+
+ this._device.connectObject(
+ 'notify::active-access-point', this._activeApChanged.bind(this),
+ 'notify::active-connection', () => this._activeConnectionChanged(),
+ 'notify::available-connections', () => this._availableConnectionsChanged(),
+ 'state-changed', () => this.notify('is-hotspot'),
+ 'access-point-added', (d, ap) => {
+ this._addAccessPoint(ap);
+ this._updateItemsVisibility();
+ },
+ 'access-point-removed', (d, ap) => {
+ this._removeAccessPoint(ap);
+ this._updateItemsVisibility();
+ }, this);
+
+ this.bind_property('single-device-mode',
+ this, 'use-submenu',
+ GObject.BindingFlags.INVERT_BOOLEAN);
+
+ Main.sessionMode.connectObject('updated',
+ () => this._updateItemsVisibility(),
+ this);
+
+ for (const ap of this._device.get_access_points())
+ this._addAccessPoint(ap);
+
+ this._activeApChanged();
+ this._activeConnectionChanged();
+ this._availableConnectionsChanged();
+ this._updateItemsVisibility();
+
+ this.connect('destroy', () => {
+ for (const net of this._networkItems.keys())
+ net.destroy();
+ });
+ }
+
+ get icon_name() {
+ if (!this._device.client.wireless_enabled)
+ return 'network-wireless-disabled-symbolic';
+
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-wireless-acquiring-symbolic';
+
+ case NM.ActiveConnectionState.ACTIVATED: {
+ if (this.is_hotspot)
+ return 'network-wireless-hotspot-symbolic';
+
+ if (!this._canReachInternet())
+ return 'network-wireless-no-route-symbolic';
+
+ if (!this._activeAccessPoint) {
+ if (this._device.mode !== NM80211Mode.ADHOC)
+ console.info('An active wireless connection, in infrastructure mode, involves no access point?');
+
+ return 'network-wireless-connected-symbolic';
+ }
+
+ const {strength} = this._activeAccessPoint;
+ return `network-wireless-signal-${signalToIcon(strength)}-symbolic`;
+ }
+ default:
+ return 'network-wireless-signal-none-symbolic';
+ }
+ }
+
+ get name() {
+ if (this.is_hotspot)
+ /* Translators: %s is a network identifier */
+ return _('%s Hotspot').format(this._deviceName);
+
+ const {ssid} = this._activeAccessPoint ?? {};
+ if (ssid)
+ return ssidToLabel(ssid);
+
+ return this._deviceName;
+ }
+
+ get is_hotspot() {
+ if (!this._device.active_connection)
+ return false;
+
+ const {connection} = this._device.active_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;
+ }
+
+ activate() {
+ if (!this.is_hotspot)
+ return;
+
+ const {activeConnection} = this._device;
+ this._client.deactivate_connection_async(activeConnection, null, null);
+ }
+
+ _activeApChanged() {
+ this._activeAccessPoint?.disconnectObject(this);
+ this._activeAccessPoint = this._device.active_access_point;
+ this._activeAccessPoint?.connectObject(
+ 'notify::strength', () => this.notify('icon-name'),
+ 'notify::ssid', () => this.notify('name'),
+ this);
+
+ this.notify('icon-name');
+ this.notify('name');
+ }
+
+ _activeConnectionChanged() {
+ this._setActiveConnection(this._device.active_connection);
+ }
+
+ _availableConnectionsChanged() {
+ const connections = this._device.get_available_connections();
+ for (const net of this._networkItems.keys())
+ net.checkConnections(connections);
+ }
+
+ _addAccessPoint(ap) {
+ if (ap.get_ssid() == null) {
+ // This access point is not visible yet
+ // Wait for it to get a ssid
+ ap.connectObject('notify::ssid', () => {
+ if (!ap.ssid)
+ return;
+ ap.disconnectObject(this);
+ this._addAccessPoint(ap);
+ }, this);
+ return;
+ }
+
+ let network = [...this._networkItems.keys()]
+ .find(n => n.checkAccessPoint(ap));
+
+ if (!network) {
+ network = new WirelessNetwork(this._device);
+
+ const item = new NMWirelessNetworkItem(network);
+ item.connect('activate', () => network.activate());
+
+ network.connectObject(
+ 'notify::icon-name', () => this._resortItem(item),
+ 'notify::is-active', () => this._resortItem(item),
+ this);
+
+ const pos = this._itemSorter.upsert(item);
+ this.section.addMenuItem(item, pos);
+ this._networkItems.set(network, item);
+ }
+
+ network.addAccessPoint(ap);
+ }
+
+ _removeAccessPoint(ap) {
+ const network = [...this._networkItems.keys()]
+ .find(n => n.removeAccessPoint(ap));
+
+ if (!network || network.hasAccessPoints())
+ return;
+
+ const item = this._networkItems.get(network);
+ this._itemSorter.delete(item);
+ this._networkItems.delete(network);
+
+ item?.destroy();
+ network.destroy();
+ }
+
+ _resortItem(item) {
+ const pos = this._itemSorter.upsert(item);
+ this.section.moveMenuItem(item, pos);
+
+ this._updateItemsVisibility();
+ }
+
+ _updateItemsVisibility() {
+ const {hasWindows} = Main.sessionMode;
+
+ let nVisible = 0;
+ for (const item of this._itemSorter) {
+ const {network: net} = item;
+ item.visible =
+ (hasWindows || net.hasConnections() || net.canAutoconnect()) &&
+ nVisible < MAX_VISIBLE_NETWORKS;
+ if (item.visible)
+ nVisible++;
+ }
+ }
+
+ setDeviceName(name) {
+ this._deviceName = name;
+ this.notify('name');
+ }
+
+ _canReachInternet() {
+ if (this._client.primary_connection !== this._device.active_connection)
+ return true;
+
+ return this._client.connectivity === NM.ConnectivityState.FULL;
+ }
+});
+
+const NMVpnConnectionItem = GObject.registerClass({
+ Signals: {
+ 'activation-failed': {},
+ },
+}, class NMVpnConnectionItem extends NMConnectionItem {
+ constructor(section, connection) {
+ super(section, connection);
+
+ this._label.x_expand = true;
+ this.accessible_role = Atk.Role.CHECK_MENU_ITEM;
+ this._icon.hide();
+
+ this._switch = new PopupMenu.Switch(this.is_active);
+ this.add_child(this._switch);
+
+ this.bind_property('is-active',
+ this._switch, 'state',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('name',
+ this._label, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ _sync() {
+ if (this.is_active)
+ this.add_accessible_state(Atk.StateType.CHECKED);
+ else
+ this.remove_accessible_state(Atk.StateType.CHECKED);
+ }
+
+ _activeConnectionStateChanged() {
+ const state = this._activeConnection?.get_state();
+ const reason = this._activeConnection?.get_state_reason();
+
+ if (state === NM.ActiveConnectionState.DEACTIVATED &&
+ reason !== NM.ActiveConnectionStateReason.NO_SECRETS &&
+ reason !== NM.ActiveConnectionStateReason.USER_DISCONNECTED)
+ this.emit('activation-failed');
+
+ super._activeConnectionStateChanged();
+ }
+
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-vpn-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED:
+ return 'network-vpn-symbolic';
+ default:
+ return 'network-vpn-disabled-symbolic';
+ }
+ }
+
+ set icon_name(_ignored) {
+ }
+});
+
+const NMToggle = GObject.registerClass({
+ Signals: {
+ 'activation-failed': {},
+ },
+}, class NMToggle extends QuickMenuToggle {
+ constructor() {
+ super();
+
+ this._items = new Map();
+ this._itemSorter = new ItemSorter({trackMru: true});
+
+ this._itemsSection = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(this._itemsSection);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._itemBinding = new GObject.BindingGroup();
+ this._itemBinding.bind('icon-name',
+ this, 'icon-name', GObject.BindingFlags.DEFAULT);
+ this._itemBinding.bind_full('name',
+ this, 'label', GObject.BindingFlags.DEFAULT,
+ (bind, source) => [true, this._transformLabel(source)],
+ null);
+
+ this.connect('clicked', () => this.activate());
+ }
+
+ setClient(client) {
+ if (this._client === client)
+ return;
+
+ this._client?.disconnectObject(this);
+ this._client = client;
+ this._client?.connectObject(
+ 'notify::networking-enabled', () => this._sync(),
+ this);
+
+ this._items.forEach(item => item.destroy());
+ this._items.clear();
+
+ if (this._client)
+ this._loadInitialItems();
+ this._sync();
+ }
+
+ activate() {
+ const activeItems = [...this._getActiveItems()];
+
+ if (activeItems.length > 0)
+ activeItems.forEach(i => i.activate());
+ else
+ this._itemBinding.source?.activate();
+ }
+
+ _loadInitialItems() {
+ throw new GObject.NotImplementedError();
+ }
+
+ // transform function for property binding:
+ // Ignore the provided label if there are multiple active
+ // items, and replace it with something like "VPN (2)"
+ _transformLabel(source) {
+ const nActive = this.checked
+ ? [...this._getActiveItems()].length
+ : 0;
+ if (nActive > 1)
+ return `${this._getDefaultName()} (${nActive})`;
+ return source;
+ }
+
+ _updateItemsVisibility() {
+ [...this._itemSorter.itemsByMru()].forEach(
+ (item, i) => (item.visible = i < MAX_VISIBLE_NETWORKS));
+ }
+
+ _itemActiveChanged() {
+ // force an update in case we changed
+ // from or to multiple active items
+ this._itemBinding.source?.notify('name');
+ this._sync();
+ }
+
+ _updateChecked() {
+ const [firstActive] = this._getActiveItems();
+ this.checked = !!firstActive;
+ }
+
+ _resortItem(item) {
+ const pos = this._itemSorter.upsert(item);
+ this._itemsSection.moveMenuItem(item, pos);
+ }
+
+ _addItem(key, item) {
+ console.assert(!this._items.has(key),
+ `${this} already has an item for ${key}`);
+
+ item.connectObject(
+ 'notify::is-active', () => this._itemActiveChanged(),
+ 'notify::name', () => this._resortItem(item),
+ 'destroy', () => this._removeItem(key),
+ this);
+
+ this._items.set(key, item);
+ const pos = this._itemSorter.upsert(item);
+ this._itemsSection.addMenuItem(item, pos);
+ this._sync();
+ }
+
+ _removeItem(key) {
+ const item = this._items.get(key);
+ if (!item)
+ return;
+
+ this._itemSorter.delete(item);
+ this._items.delete(key);
+
+ item.destroy();
+ this._sync();
+ }
+
+ *_getActiveItems() {
+ for (const item of this._itemSorter) {
+ if (item.is_active)
+ yield item;
+ }
+ }
+
+ _getPrimaryItem() {
+ // prefer active items
+ const [firstActive] = this._getActiveItems();
+ if (firstActive)
+ return firstActive;
+
+ // otherwise prefer the most-recently used
+ const [lastUsed] = this._itemSorter.itemsByMru();
+ if (lastUsed?.timestamp > 0)
+ return lastUsed;
+
+ // as a last resort, return the top-most visible item
+ for (const item of this._itemSorter) {
+ if (item.visible)
+ return item;
+ }
+
+ console.assert(!this.visible,
+ `${this} should not be visible when empty`);
+
+ return null;
+ }
+
+ _sync() {
+ this.visible =
+ this._client?.networking_enabled && this._items.size > 0;
+ this._updateItemsVisibility();
+ this._updateChecked();
+ this._itemBinding.source = this._getPrimaryItem();
+ }
+});
+
+const NMVpnToggle = GObject.registerClass(
+class NMVpnToggle extends NMToggle {
+ constructor() {
+ super();
+
+ this.menu.setHeader('network-vpn-symbolic', _('VPN'));
+ this.menu.addSettingsAction(_('VPN Settings'),
+ 'gnome-network-panel.desktop');
+ }
+
+ setClient(client) {
+ super.setClient(client);
+
+ this._client?.connectObject(
+ 'connection-added', (c, conn) => this._addConnection(conn),
+ 'connection-removed', (c, conn) => this._removeConnection(conn),
+ 'notify::active-connections', () => this._syncActiveConnections(),
+ this);
+ }
+
+ _getDefaultName() {
+ return _('VPN');
+ }
+
+ _loadInitialItems() {
+ const connections = this._client.get_connections();
+ for (const conn of connections)
+ this._addConnection(conn);
+
+ this._syncActiveConnections();
+ }
+
+ _syncActiveConnections() {
+ const activeConnections =
+ this._client.get_active_connections().filter(
+ c => this._shouldHandleConnection(c.connection));
+
+ for (const item of this._items.values())
+ item.setActiveConnection(null);
+
+ for (const a of activeConnections)
+ this._items.get(a.connection)?.setActiveConnection(a);
+ }
+
+ _shouldHandleConnection(connection) {
+ const setting = connection.get_setting_connection();
+ if (!setting)
+ return false;
+
+ // Ignore slave connection
+ if (setting.get_master())
+ return false;
+
+ const handledTypes = [
+ NM.SETTING_VPN_SETTING_NAME,
+ NM.SETTING_WIREGUARD_SETTING_NAME,
+ ];
+ return handledTypes.includes(setting.type);
+ }
+
+ _onConnectionChanged(connection) {
+ const item = this._items.get(connection);
+ item.updateForConnection(connection);
+ }
+
+ _addConnection(connection) {
+ if (this._items.has(connection))
+ return;
+
+ if (!this._shouldHandleConnection(connection))
+ return;
+
+ connection.connectObject(
+ 'changed', this._onConnectionChanged.bind(this),
+ this);
+
+ const item = new NMVpnConnectionItem(this, connection);
+ item.connectObject(
+ 'activation-failed', () => this.emit('activation-failed'),
+ this);
+ this._addItem(connection, item);
+ }
+
+ _removeConnection(connection) {
+ this._removeItem(connection);
+ }
+
+ activateConnection(connection) {
+ this._client.activate_connection_async(connection, null, null, null, null);
+ }
+
+ deactivateConnection(activeConnection) {
+ this._client.deactivate_connection(activeConnection, null);
+ }
+});
+
+const NMDeviceToggle = GObject.registerClass(
+class NMDeviceToggle extends NMToggle {
+ constructor(deviceType) {
+ super();
+
+ this._deviceType = deviceType;
+ this._nmDevices = new Set();
+ this._deviceNames = new Map();
+ }
+
+ setClient(client) {
+ this._nmDevices.clear();
+
+ super.setClient(client);
+
+ this._client?.connectObject(
+ 'device-added', (c, dev) => {
+ this._addDevice(dev);
+ this._syncDeviceNames();
+ },
+ 'device-removed', (c, dev) => {
+ this._removeDevice(dev);
+ this._syncDeviceNames();
+ }, this);
+ }
+
+ _getDefaultName() {
+ const [dev] = this._nmDevices;
+ const [name] = NM.Device.disambiguate_names([dev]);
+ return name;
+ }
+
+ _loadInitialItems() {
+ const devices = this._client.get_devices();
+ for (const dev of devices)
+ this._addDevice(dev);
+ this._syncDeviceNames();
+ }
+
+ _shouldShowDevice(device) {
+ switch (device.state) {
+ case NM.DeviceState.DISCONNECTED:
+ case NM.DeviceState.ACTIVATED:
+ case NM.DeviceState.DEACTIVATING:
+ case NM.DeviceState.PREPARE:
+ case NM.DeviceState.CONFIG:
+ case NM.DeviceState.IP_CONFIG:
+ case NM.DeviceState.IP_CHECK:
+ case NM.DeviceState.SECONDARIES:
+ case NM.DeviceState.NEED_AUTH:
+ case NM.DeviceState.FAILED:
+ return true;
+ case NM.DeviceState.UNMANAGED:
+ case NM.DeviceState.UNAVAILABLE:
+ default:
+ return false;
+ }
+ }
+
+ _syncDeviceNames() {
+ const devices = [...this._nmDevices];
+ const names = NM.Device.disambiguate_names(devices);
+ this._deviceNames.clear();
+ devices.forEach(
+ (dev, i) => {
+ this._deviceNames.set(dev, names[i]);
+ this._items.get(dev)?.setDeviceName(names[i]);
+ });
+ }
+
+ _syncDeviceItem(device) {
+ if (this._shouldShowDevice(device))
+ this._ensureDeviceItem(device);
+ else
+ this._removeDeviceItem(device);
+ }
+
+ _deviceStateChanged(device, newState, oldState, reason) {
+ if (newState === oldState) {
+ console.info(`${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');
+ }
+
+ _createDeviceMenuItem(_device) {
+ throw new GObject.NotImplementedError();
+ }
+
+ _ensureDeviceItem(device) {
+ if (this._items.has(device))
+ return;
+
+ const item = this._createDeviceMenuItem(device);
+ item.setDeviceName(this._deviceNames.get(device) ?? '');
+ this._addItem(device, item);
+ }
+
+ _removeDeviceItem(device) {
+ this._removeItem(device);
+ }
+
+ _addDevice(device) {
+ if (this._nmDevices.has(device))
+ return;
+
+ if (device.get_device_type() !== this._deviceType)
+ return;
+
+ device.connectObject(
+ 'state-changed', this._deviceStateChanged.bind(this),
+ 'notify::interface', () => this._syncDeviceNames(),
+ 'notify::state', () => this._syncDeviceItem(device),
+ this);
+
+ this._nmDevices.add(device);
+ this._syncDeviceItem(device);
+ }
+
+ _removeDevice(device) {
+ if (!this._nmDevices.delete(device))
+ return;
+
+ device.disconnectObject(this);
+ this._removeDeviceItem(device);
+ }
+
+ _sync() {
+ super._sync();
+
+ const nItems = this._items.size;
+ this._items.forEach(item => (item.singleDeviceMode = nItems === 1));
+ }
+});
+
+const NMWirelessToggle = GObject.registerClass(
+class NMWirelessToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.WIFI);
+
+ this._itemBinding.bind('is-hotspot',
+ this, 'menu-enabled',
+ GObject.BindingFlags.INVERT_BOOLEAN);
+
+ this._scanningSpinner = new Spinner(16);
+
+ this.menu.connectObject('open-state-changed', (m, isOpen) => {
+ if (isOpen)
+ this._startScanning();
+ else
+ this._stopScanning();
+ });
+
+ this.menu.setHeader('network-wireless-symbolic', _('Wi–Fi'));
+ this.menu.addHeaderSuffix(this._scanningSpinner);
+ this.menu.addSettingsAction(_('All Networks'),
+ 'gnome-wifi-panel.desktop');
+ }
+
+ setClient(client) {
+ super.setClient(client);
+
+ this._client?.bind_property('wireless-enabled',
+ this, 'checked',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._client?.bind_property('wireless-hardware-enabled',
+ this, 'reactive',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ activate() {
+ const primaryItem = this._itemBinding.source;
+ if (primaryItem?.is_hotspot)
+ primaryItem.activate();
+ else
+ this._client.wireless_enabled = !this._client.wireless_enabled;
+ }
+
+ async _scanDevice(device) {
+ const {lastScan} = device;
+ await device.request_scan_async(null);
+
+ // Wait for the lastScan property to update, which
+ // indicates the end of the scan
+ return new Promise(resolve => {
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1500, () => {
+ if (device.lastScan === lastScan)
+ return GLib.SOURCE_CONTINUE;
+
+ resolve();
+ return GLib.SOURCE_REMOVE;
+ });
+ });
+ }
+
+ async _scanDevices() {
+ if (!this._client.wireless_enabled)
+ return;
+
+ this._scanningSpinner.play();
+
+ const devices = [...this._items.keys()];
+ await Promise.all(
+ devices.map(d => this._scanDevice(d)));
+
+ this._scanningSpinner.stop();
+ }
+
+ _startScanning() {
+ this._scanTimeoutId = GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT, WIFI_SCAN_FREQUENCY, () => {
+ this._scanDevices().catch(logError);
+ return GLib.SOURCE_CONTINUE;
+ });
+ this._scanDevices().catch(logError);
+ }
+
+ _stopScanning() {
+ if (this._scanTimeoutId)
+ GLib.source_remove(this._scanTimeoutId);
+ delete this._scanTimeoutId;
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMWirelessDeviceItem(this._client, device);
+ }
+
+ _updateChecked() {
+ // handled via a property binding
+ }
+
+ _getPrimaryItem() {
+ const hotspot = [...this._items.values()].find(i => i.is_hotspot);
+ if (hotspot)
+ return hotspot;
+
+ return super._getPrimaryItem();
+ }
+
+ _shouldShowDevice(device) {
+ // don't disappear if wireless-enabled is false
+ if (device.state === NM.DeviceState.UNAVAILABLE)
+ return true;
+ return super._shouldShowDevice(device);
+ }
+});
+
+const NMWiredToggle = GObject.registerClass(
+class NMWiredToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.ETHERNET);
+
+ this.menu.setHeader('network-wired-symbolic', _('Wired Connections'));
+ this.menu.addSettingsAction(_('Wired Settings'),
+ 'gnome-network-panel.desktop');
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMWiredDeviceItem(this._client, device);
+ }
+});
+
+const NMBluetoothToggle = GObject.registerClass(
+class NMBluetoothToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.BT);
+
+ this.menu.setHeader('network-cellular-symbolic', _('Bluetooth Tethers'));
+ this.menu.addSettingsAction(_('Bluetooth Settings'),
+ 'gnome-network-panel.desktop');
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMBluetoothDeviceItem(this._client, device);
+ }
+});
+
+const NMModemToggle = GObject.registerClass(
+class NMModemToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.MODEM);
+
+ this.menu.setHeader('network-cellular-symbolic', _('Mobile Connections'));
+
+ const settingsLabel = _('Mobile Broadband Settings');
+ this._wwanSettings = this.menu.addSettingsAction(settingsLabel,
+ 'gnome-wwan-panel.desktop');
+ this._legacySettings = this.menu.addSettingsAction(settingsLabel,
+ 'gnome-network-panel.desktop');
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMModemDeviceItem(this._client, device);
+ }
+
+ _sync() {
+ super._sync();
+
+ const useWwanPanel =
+ [...this._items.values()].some(i => i.wwanPanelSupported);
+ this._wwanSettings.visible = useWwanPanel;
+ this._legacySettings.visible = !useWwanPanel;
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._connectivityQueue = new Set();
+
+ this._mainConnection = null;
+
+ this._notification = null;
+
+ this._wiredToggle = new NMWiredToggle();
+ this._wirelessToggle = new NMWirelessToggle();
+ this._modemToggle = new NMModemToggle();
+ this._btToggle = new NMBluetoothToggle();
+ this._vpnToggle = new NMVpnToggle();
+
+ this._deviceToggles = new Map([
+ [NM.DeviceType.ETHERNET, this._wiredToggle],
+ [NM.DeviceType.WIFI, this._wirelessToggle],
+ [NM.DeviceType.MODEM, this._modemToggle],
+ [NM.DeviceType.BT, this._btToggle],
+ ]);
+ this.quickSettingsItems.push(...this._deviceToggles.values());
+ this.quickSettingsItems.push(this._vpnToggle);
+
+ this.quickSettingsItems.forEach(toggle => {
+ toggle.connectObject(
+ 'activation-failed', () => this._onActivationFailed(),
+ this);
+ });
+
+ this._primaryIndicator = this._addIndicator();
+ this._vpnIndicator = this._addIndicator();
+
+ this._primaryIndicatorBinding = new GObject.BindingGroup();
+ this._primaryIndicatorBinding.bind('icon-name',
+ this._primaryIndicator, 'icon-name',
+ GObject.BindingFlags.DEFAULT);
+
+ this._vpnToggle.bind_property('checked',
+ this._vpnIndicator, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._vpnToggle.bind_property('icon-name',
+ this._vpnIndicator, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._getClient().catch(logError);
+ }
+
+ async _getClient() {
+ this._client = await NM.Client.new_async(null);
+
+ this.quickSettingsItems.forEach(
+ toggle => toggle.setClient(this._client));
+
+ this._client.bind_property('nm-running',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._client.connectObject(
+ 'notify::primary-connection', () => this._syncMainConnection(),
+ 'notify::activating-connection', () => this._syncMainConnection(),
+ 'notify::connectivity', () => this._syncConnectivity(),
+ this);
+ this._syncMainConnection();
+
+ try {
+ this._configPermission = await Polkit.Permission.new(
+ 'org.freedesktop.NetworkManager.network-control', null, null);
+
+ this.quickSettingsItems.forEach(toggle => {
+ this._configPermission.bind_property('allowed',
+ toggle, 'reactive',
+ GObject.BindingFlags.SYNC_CREATE);
+ });
+ } catch (e) {
+ log(`No permission to control network connections: ${e}`);
+ this._configPermission = null;
+ }
+ }
+
+ _onActivationFailed() {
+ this._notification?.destroy();
+
+ const source = new MessageTray.Source(
+ _('Network Manager'), 'network-error-symbolic');
+ source.policy =
+ new MessageTray.NotificationApplicationPolicy('gnome-network-panel');
+
+ this._notification = new MessageTray.Notification(source,
+ _('Connection failed'),
+ _('Activation of network connection failed'));
+ this._notification.setUrgency(MessageTray.Urgency.HIGH);
+ this._notification.setTransient(true);
+ this._notification.connect('destroy',
+ () => (this._notification = null));
+
+ Main.messageTray.add(source);
+ source.showNotification(this._notification);
+ }
+
+ _syncMainConnection() {
+ this._mainConnection?.disconnectObject(this);
+
+ this._mainConnection =
+ this._client.get_primary_connection() ||
+ this._client.get_activating_connection();
+
+ if (this._mainConnection) {
+ this._mainConnection.connectObject('notify::state',
+ this._mainConnectionStateChanged.bind(this), this);
+ this._mainConnectionStateChanged();
+ }
+
+ this._updateIcon();
+ this._syncConnectivity();
+ }
+
+ _mainConnectionStateChanged() {
+ if (this._mainConnection.state === NM.ActiveConnectionState.ACTIVATED)
+ this._notification?.destroy();
+ }
+
+ _flushConnectivityQueue() {
+ for (let item of this._connectivityQueue)
+ this._portalHelperProxy?.CloseAsync(item);
+ this._connectivityQueue.clear();
+ }
+
+ _closeConnectivityCheck(path) {
+ if (this._connectivityQueue.delete(path))
+ this._portalHelperProxy?.CloseAsync(path);
+ }
+
+ async _portalHelperDone(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 they choose 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: ${result}`);
+ }
+ }
+
+ async _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 ||= this._client.connectivity < NM.ConnectivityState.FULL;
+ if (!isPortal || Main.sessionMode.isGreeter)
+ return;
+
+ let path = this._mainConnection.get_path();
+ if (this._connectivityQueue.has(path))
+ return;
+
+ let timestamp = global.get_current_time();
+ if (!this._portalHelperProxy) {
+ this._portalHelperProxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: 'org.gnome.Shell.PortalHelper',
+ g_object_path: '/org/gnome/Shell/PortalHelper',
+ g_interface_name: PortalHelperInfo.name,
+ g_interface_info: PortalHelperInfo,
+ });
+ this._portalHelperProxy.connectSignal('Done',
+ (proxy, emitter, params) => {
+ this._portalHelperDone(params).catch(logError);
+ });
+
+ try {
+ await this._portalHelperProxy.init_async(
+ GLib.PRIORITY_DEFAULT, null);
+ } catch (e) {
+ console.error(`Error launching the portal helper: ${e.message}`);
+ }
+ }
+
+ this._portalHelperProxy?.AuthenticateAsync(path, this._client.connectivity_check_uri, timestamp).catch(logError);
+
+ this._connectivityQueue.add(path);
+ }
+
+ _updateIcon() {
+ const [dev] = this._mainConnection?.get_devices() ?? [];
+ const primaryToggle = this._deviceToggles.get(dev?.device_type) ?? null;
+ this._primaryIndicatorBinding.source = primaryToggle;
+
+ if (!primaryToggle) {
+ if (this._client.connectivity === NM.ConnectivityState.FULL)
+ this._primaryIndicator.icon_name = 'network-wired-symbolic';
+ else
+ this._primaryIndicator.icon_name = 'network-wired-no-route-symbolic';
+ }
+
+ const state = this._client.get_state();
+ const connected = state === NM.State.CONNECTED_GLOBAL;
+ this._primaryIndicator.visible = (primaryToggle != null) || connected;
+ }
+});
diff --git a/js/ui/status/nightLight.js b/js/ui/status/nightLight.js
new file mode 100644
index 0000000..0d148e3
--- /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, GLib, GObject} = imports.gi;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+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 colorInfo = Gio.DBusInterfaceInfo.new_for_xml(ColorInterface);
+
+const NightLightToggle = GObject.registerClass(
+class NightLightToggle extends QuickToggle {
+ _init() {
+ super._init({
+ label: _('Night Light'),
+ iconName: 'night-light-symbolic',
+ toggleMode: true,
+ });
+
+ const monitorManager = global.backend.get_monitor_manager();
+ monitorManager.bind_property('night-light-supported',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.settings-daemon.plugins.color',
+ });
+ this._settings.bind('night-light-enabled',
+ this, 'checked',
+ Gio.SettingsBindFlags.DEFAULT);
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'night-light-symbolic';
+
+ this.quickSettingsItems.push(new NightLightToggle());
+
+ this._proxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: BUS_NAME,
+ g_object_path: OBJECT_PATH,
+ g_interface_name: colorInfo.name,
+ g_interface_info: colorInfo,
+ });
+ this._proxy.connect('g-properties-changed', (p, properties) => {
+ const nightLightActiveChanged = !!properties.lookup_value('NightLightActive', null);
+ if (nightLightActiveChanged)
+ this._sync();
+ });
+ this._proxy.init_async(GLib.PRIORITY_DEFAULT, null)
+ .catch(e => console.error(e.message));
+
+ this._sync();
+ }
+
+ _sync() {
+ this._indicator.visible = this._proxy.NightLightActive;
+ }
+});
diff --git a/js/ui/status/powerProfiles.js b/js/ui/status/powerProfiles.js
new file mode 100644
index 0000000..e15208d
--- /dev/null
+++ b/js/ui/status/powerProfiles.js
@@ -0,0 +1,126 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GObject} = imports.gi;
+
+const {QuickMenuToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const PopupMenu = imports.ui.popupMenu;
+
+const {loadInterfaceXML} = imports.misc.fileUtils;
+
+const BUS_NAME = 'net.hadess.PowerProfiles';
+const OBJECT_PATH = '/net/hadess/PowerProfiles';
+
+const PowerProfilesIface = loadInterfaceXML('net.hadess.PowerProfiles');
+const PowerProfilesProxy = Gio.DBusProxy.makeProxyWrapper(PowerProfilesIface);
+
+const PROFILE_PARAMS = {
+ 'performance': {
+ label: C_('Power profile', 'Performance'),
+ iconName: 'power-profile-performance-symbolic',
+ },
+
+ 'balanced': {
+ label: C_('Power profile', 'Balanced'),
+ iconName: 'power-profile-balanced-symbolic',
+ },
+
+ 'power-saver': {
+ label: C_('Power profile', 'Power Saver'),
+ iconName: 'power-profile-power-saver-symbolic',
+ },
+};
+
+const LAST_PROFILE_KEY = 'last-selected-power-profile';
+
+const PowerProfilesToggle = GObject.registerClass(
+class PowerProfilesToggle extends QuickMenuToggle {
+ _init() {
+ super._init();
+
+ this._profileItems = new Map();
+
+ this.connect('clicked', () => {
+ this._proxy.ActiveProfile = this.checked
+ ? 'balanced'
+ : global.settings.get_string(LAST_PROFILE_KEY);
+ });
+
+ this._proxy = new PowerProfilesProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ } else {
+ this._proxy.connect('g-properties-changed', (p, properties) => {
+ const profilesChanged = !!properties.lookup_value('Profiles', null);
+ if (profilesChanged)
+ this._syncProfiles();
+ this._sync();
+ });
+
+ if (this._proxy.g_name_owner)
+ this._syncProfiles();
+ }
+ this._sync();
+ });
+
+ this._profileSection = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(this._profileSection);
+ this.menu.setHeader('power-profile-balanced-symbolic', _('Power Profiles'));
+
+ this._sync();
+ }
+
+ _syncProfiles() {
+ this._profileSection.removeAll();
+ this._profileItems.clear();
+
+ const profiles = this._proxy.Profiles
+ .map(p => p.Profile.unpack())
+ .reverse();
+ for (const profile of profiles) {
+ const {label, iconName} = PROFILE_PARAMS[profile];
+ if (!label)
+ continue;
+
+ const item = new PopupMenu.PopupImageMenuItem(label, iconName);
+ item.connect('activate',
+ () => (this._proxy.ActiveProfile = profile));
+ this._profileItems.set(profile, item);
+ this._profileSection.addMenuItem(item);
+ }
+
+ this.menuEnabled = this._profileItems.size > 2;
+ }
+
+ _sync() {
+ this.visible = this._proxy.g_name_owner !== null;
+
+ if (!this.visible)
+ return;
+
+ const {ActiveProfile: activeProfile} = this._proxy;
+
+ for (const [profile, item] of this._profileItems) {
+ item.setOrnament(profile === activeProfile
+ ? PopupMenu.Ornament.CHECK
+ : PopupMenu.Ornament.NONE);
+ }
+
+ this.set(PROFILE_PARAMS[activeProfile]);
+ this.checked = activeProfile !== 'balanced';
+
+ if (this.checked)
+ global.settings.set_string(LAST_PROFILE_KEY, activeProfile);
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new PowerProfilesToggle());
+ }
+});
diff --git a/js/ui/status/remoteAccess.js b/js/ui/status/remoteAccess.js
new file mode 100644
index 0000000..1ed8793
--- /dev/null
+++ b/js/ui/status/remoteAccess.js
@@ -0,0 +1,230 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported RemoteAccessApplet, ScreenRecordingIndicator, ScreenSharingIndicator */
+
+const { Atk, Clutter, GLib, GObject, Meta, St } = imports.gi;
+
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const {SystemIndicator} = imports.ui.quickSettings;
+
+// Minimum amount of time the shared indicator is visible (in micro seconds)
+const MIN_SHARED_INDICATOR_VISIBLE_TIME_US = 5 * GLib.TIME_SPAN_SECOND;
+
+var RemoteAccessApplet = GObject.registerClass(
+class RemoteAccessApplet extends SystemIndicator {
+ _init() {
+ super._init();
+
+ let controller = global.backend.get_remote_access_controller();
+
+ if (!controller)
+ return;
+
+ this._handles = new Set();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'media-record-symbolic';
+ this._indicator.add_style_class_name('screencast-indicator');
+
+ controller.connect('new-handle', (o, handle) => {
+ this._onNewHandle(handle);
+ });
+ this._sync();
+ }
+
+ _isRecording() {
+ // Screenshot UI screencasts have their own panel, so don't show this
+ // indicator if there's only a screenshot UI screencast.
+ if (Main.screenshotUI.screencast_in_progress)
+ return this._handles.size > 1;
+
+ return this._handles.size > 0;
+ }
+
+ _sync() {
+ this._indicator.visible = this._isRecording();
+ }
+
+ _onStopped(handle) {
+ this._handles.delete(handle);
+ this._sync();
+ }
+
+ _onNewHandle(handle) {
+ if (!handle.is_recording)
+ return;
+
+ this._handles.add(handle);
+ handle.connect('stopped', this._onStopped.bind(this));
+
+ this._sync();
+ }
+});
+
+var ScreenRecordingIndicator = GObject.registerClass({
+ Signals: { 'menu-set': {} },
+}, class ScreenRecordingIndicator extends PanelMenu.ButtonBox {
+ _init() {
+ super._init({
+ reactive: true,
+ can_focus: true,
+ track_hover: true,
+ accessible_name: _('Stop Screencast'),
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ });
+ this.add_style_class_name('screen-recording-indicator');
+
+ this._box = new St.BoxLayout();
+ this.add_child(this._box);
+
+ this._label = new St.Label({
+ text: '0:00',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._box.add_child(this._label);
+
+ this._icon = new St.Icon({ icon_name: 'stop-symbolic' });
+ this._box.add_child(this._icon);
+
+ this.hide();
+ Main.screenshotUI.connect(
+ 'notify::screencast-in-progress',
+ this._onScreencastInProgressChanged.bind(this));
+ }
+
+ vfunc_event(event) {
+ if (event.type() === Clutter.EventType.TOUCH_BEGIN ||
+ event.type() === Clutter.EventType.BUTTON_PRESS)
+ Main.screenshotUI.stopScreencast();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _updateLabel() {
+ const minutes = this._secondsPassed / 60;
+ const seconds = this._secondsPassed % 60;
+ this._label.text = '%d:%02d'.format(minutes, seconds);
+ }
+
+ _onScreencastInProgressChanged() {
+ if (Main.screenshotUI.screencast_in_progress) {
+ this.show();
+
+ this._secondsPassed = 0;
+ this._updateLabel();
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => {
+ this._secondsPassed += 1;
+ this._updateLabel();
+ return GLib.SOURCE_CONTINUE;
+ });
+ GLib.Source.set_name_by_id(
+ this._timeoutId, '[gnome-shell] screen recording indicator tick');
+ } else {
+ this.hide();
+
+ GLib.source_remove(this._timeoutId);
+ delete this._timeoutId;
+
+ delete this._secondsPassed;
+ }
+ }
+});
+
+var ScreenSharingIndicator = GObject.registerClass({
+ Signals: {'menu-set': {}},
+}, class ScreenSharingIndicator extends PanelMenu.ButtonBox {
+ _init() {
+ super._init({
+ reactive: true,
+ can_focus: true,
+ track_hover: true,
+ accessible_name: _('Stop Screen Sharing'),
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ });
+ this.add_style_class_name('screen-sharing-indicator');
+
+ this._box = new St.BoxLayout();
+ this.add_child(this._box);
+
+ let icon = new St.Icon({icon_name: 'screen-shared-symbolic'});
+ this._box.add_child(icon);
+
+ icon = new St.Icon({icon_name: 'window-close-symbolic'});
+ this._box.add_child(icon);
+
+ this._controller = global.backend.get_remote_access_controller();
+
+ this._handles = new Set();
+
+ this._controller?.connect('new-handle',
+ (o, handle) => this._onNewHandle(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.
+ if (!Meta.is_wayland_compositor())
+ return;
+
+ if (handle.isRecording)
+ return;
+
+ this._handles.add(handle);
+ handle.connect('stopped', () => {
+ this._handles.delete(handle);
+ this._sync();
+ });
+ this._sync();
+ }
+
+ vfunc_event(event) {
+ if (event.type() === Clutter.EventType.TOUCH_BEGIN ||
+ event.type() === Clutter.EventType.BUTTON_PRESS)
+ this._stopSharing();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _stopSharing() {
+ for (const handle of this._handles)
+ handle.stop();
+ }
+
+ _hideIndicator() {
+ this.hide();
+ delete this._hideIndicatorId;
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _sync() {
+ if (this._hideIndicatorId) {
+ GLib.source_remove(this._hideIndicatorId);
+ delete this._hideIndicatorId;
+ }
+
+ if (this._handles.size > 0) {
+ if (!this.visible)
+ this._visibleTimeUs = GLib.get_monotonic_time();
+ this.show();
+ } else if (this.visible) {
+ const currentTimeUs = GLib.get_monotonic_time();
+ const timeSinceVisibleUs = currentTimeUs - this._visibleTimeUs;
+
+ if (timeSinceVisibleUs >= MIN_SHARED_INDICATOR_VISIBLE_TIME_US) {
+ this._hideIndicator();
+ } else {
+ const timeUntilHideUs =
+ MIN_SHARED_INDICATOR_VISIBLE_TIME_US - timeSinceVisibleUs;
+ this._hideIndicatorId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ timeUntilHideUs / GLib.TIME_SPAN_MILLISECOND,
+ () => this._hideIndicator());
+ }
+ }
+ }
+});
diff --git a/js/ui/status/rfkill.js b/js/ui/status/rfkill.js
new file mode 100644
index 0000000..2e1f98f
--- /dev/null
+++ b/js/ui/status/rfkill.js
@@ -0,0 +1,136 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GLib, GObject} = imports.gi;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+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 rfkillManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(RfkillManagerInterface);
+
+const RfkillManager = GObject.registerClass({
+ Properties: {
+ 'airplane-mode': GObject.ParamSpec.boolean(
+ 'airplane-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'hw-airplane-mode': GObject.ParamSpec.boolean(
+ 'hw-airplane-mode', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'show-airplane-mode': GObject.ParamSpec.boolean(
+ 'show-airplane-mode', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+}, class RfkillManager extends GObject.Object {
+ constructor() {
+ super();
+
+ this._proxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: BUS_NAME,
+ g_object_path: OBJECT_PATH,
+ g_interface_name: rfkillManagerInfo.name,
+ g_interface_info: rfkillManagerInfo,
+ });
+ this._proxy.connect('g-properties-changed', this._changed.bind(this));
+ this._proxy.init_async(GLib.PRIORITY_DEFAULT, null)
+ .catch(e => console.error(e.message));
+ }
+
+ /* eslint-disable camelcase */
+ get airplane_mode() {
+ return this._proxy.AirplaneMode;
+ }
+
+ set airplane_mode(v) {
+ this._proxy.AirplaneMode = v;
+ }
+
+ get hw_airplane_mode() {
+ return this._proxy.HardwareAirplaneMode;
+ }
+
+ get show_airplane_mode() {
+ return this._proxy.HasAirplaneMode && this._proxy.ShouldShowAirplaneMode;
+ }
+ /* eslint-enable camelcase */
+
+ _changed(proxy, properties) {
+ for (const prop in properties.deepUnpack()) {
+ switch (prop) {
+ case 'AirplaneMode':
+ this.notify('airplane-mode');
+ break;
+ case 'HardwareAirplaneMode':
+ this.notify('hw-airplane-mode');
+ break;
+ case 'HasAirplaneMode':
+ case 'ShouldShowAirplaneMode':
+ this.notify('show-airplane-mode');
+ break;
+ }
+ }
+ }
+});
+
+var _manager;
+function getRfkillManager() {
+ if (_manager != null)
+ return _manager;
+
+ _manager = new RfkillManager();
+ return _manager;
+}
+
+const RfkillToggle = GObject.registerClass(
+class RfkillToggle extends QuickToggle {
+ _init() {
+ super._init({
+ label: _('Airplane Mode'),
+ iconName: 'airplane-mode-symbolic',
+ });
+
+ this._manager = getRfkillManager();
+ this._manager.bind_property('show-airplane-mode',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._manager.bind_property('airplane-mode',
+ this, 'checked',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.connect('clicked',
+ () => (this._manager.airplaneMode = !this._manager.airplaneMode));
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'airplane-mode-symbolic';
+
+ this._rfkillToggle = new RfkillToggle();
+ this._rfkillToggle.connectObject(
+ 'notify::visible', () => this._sync(),
+ 'notify::checked', () => this._sync(),
+ this);
+ this.quickSettingsItems.push(this._rfkillToggle);
+
+ this._sync();
+ }
+
+ _sync() {
+ // Only show indicator when airplane mode is on
+ const {visible, checked} = this._rfkillToggle;
+ this._indicator.visible = visible && checked;
+ }
+});
diff --git a/js/ui/status/system.js b/js/ui/status/system.js
new file mode 100644
index 0000000..5a2d92c
--- /dev/null
+++ b/js/ui/status/system.js
@@ -0,0 +1,348 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St, UPowerGlib: UPower} = imports.gi;
+
+const SystemActions = imports.misc.systemActions;
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const {PopupAnimation} = imports.ui.boxpointer;
+
+const {QuickSettingsItem, QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+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';
+
+const PowerToggle = GObject.registerClass({
+ Properties: {
+ 'fallback-icon-name': GObject.ParamSpec.string('fallback-icon-name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ },
+}, class PowerToggle extends QuickToggle {
+ _init() {
+ super._init({
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ });
+
+ this.add_style_class_name('power-item');
+
+ this._proxy = new PowerManagerProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error)
+ console.error(error.message);
+ else
+ this._proxy.connect('g-properties-changed', () => this._sync());
+ this._sync();
+ });
+
+ this.bind_property('fallback-icon-name',
+ this._icon, 'fallback-icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.connect('clicked', () => {
+ const app = Shell.AppSystem.get_default().lookup_app('gnome-power-panel.desktop');
+ Main.overview.hide();
+ Main.panel.closeQuickSettings();
+ app.activate();
+ });
+
+ Main.sessionMode.connect('updated', () => this._sessionUpdated());
+ this._sessionUpdated();
+ this._sync();
+ }
+
+ _sessionUpdated() {
+ this.reactive = Main.sessionMode.allowSettings;
+ }
+
+ _sync() {
+ // Do we have batteries or a UPS?
+ this.visible = this._proxy.IsPresent;
+ if (!this.visible)
+ 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-${fillLevel}${chargingState}-symbolic`;
+
+ // Make sure we fall back to fallback-icon-name and not GThemedIcon's
+ // default fallbacks
+ const gicon = new Gio.ThemedIcon({
+ name: icon,
+ use_default_fallbacks: false,
+ });
+
+ this.set({
+ label: _('%d\u2009%%').format(this._proxy.Percentage),
+ fallback_icon_name: this._proxy.IconName,
+ gicon,
+ });
+ }
+});
+
+const ScreenshotItem = GObject.registerClass(
+class ScreenshotItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'icon-button',
+ can_focus: true,
+ icon_name: 'camera-photo-symbolic',
+ visible: !Main.sessionMode.isGreeter,
+ accessible_name: _('Take Screenshot'),
+ });
+
+ this.connect('clicked', () => {
+ const topMenu = Main.panel.statusArea.quickSettings.menu;
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ Main.screenshotUI.open().catch(logError);
+ return GLib.SOURCE_REMOVE;
+ });
+ topMenu.close(PopupAnimation.NONE);
+ });
+ }
+});
+
+const SettingsItem = GObject.registerClass(
+class SettingsItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'icon-button',
+ can_focus: true,
+ child: new St.Icon(),
+ });
+
+ this._settingsApp = Shell.AppSystem.get_default().lookup_app(
+ 'org.gnome.Settings.desktop');
+
+ if (!this._settingsApp)
+ console.warn('Missing required core component Settings, expect trouble…');
+
+ this.child.gicon = this._settingsApp?.get_icon() ?? null;
+ this.accessible_name = this._settingsApp?.get_name() ?? null;
+
+ this.connect('clicked', () => {
+ Main.overview.hide();
+ Main.panel.closeQuickSettings();
+ this._settingsApp.activate();
+ });
+
+ Main.sessionMode.connectObject('updated', () => this._sync(), this);
+ this._sync();
+ }
+
+ _sync() {
+ this.visible =
+ this._settingsApp != null && Main.sessionMode.allowSettings;
+ }
+});
+
+const ShutdownItem = GObject.registerClass(
+class ShutdownItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'icon-button',
+ hasMenu: true,
+ canFocus: true,
+ icon_name: 'system-shutdown-symbolic',
+ accessible_name: _('Power Off Menu'),
+ });
+
+ this._systemActions = new SystemActions.getDefault();
+ this._items = [];
+
+ this.menu.setHeader('system-shutdown-symbolic', C_('title', 'Power Off'));
+
+ this._addSystemAction(_('Suspend'), 'can-suspend', () => {
+ this._systemActions.activateSuspend();
+ Main.panel.closeQuickSettings();
+ });
+
+ this._addSystemAction(_('Restart…'), 'can-restart', () => {
+ this._systemActions.activateRestart();
+ Main.panel.closeQuickSettings();
+ });
+
+ this._addSystemAction(_('Power Off…'), 'can-power-off', () => {
+ this._systemActions.activatePowerOff();
+ Main.panel.closeQuickSettings();
+ });
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._addSystemAction(_('Log Out…'), 'can-logout', () => {
+ this._systemActions.activateLogout();
+ Main.panel.closeQuickSettings();
+ });
+
+ this._addSystemAction(_('Switch User…'), 'can-switch-user', () => {
+ this._systemActions.activateSwitchUser();
+ Main.panel.closeQuickSettings();
+ });
+
+ // 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 item each time we become visible or
+ // the lockdown setting changes, which should be close enough.
+ this.connect('notify::mapped', () => {
+ if (!this.mapped)
+ return;
+
+ this._systemActions.forceUpdate();
+ });
+
+ this.connect('clicked', () => this.menu.open());
+ this.connect('popup-menu', () => this.menu.open());
+ }
+
+ _addSystemAction(label, propName, callback) {
+ const item = this.menu.addAction(label, callback);
+ this._items.push(item);
+
+ this._systemActions.bind_property(propName,
+ item, 'visible',
+ GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE);
+ item.connect('notify::visible', () => this._sync());
+ }
+
+ _sync() {
+ this.visible = this._items.some(i => i.visible);
+ }
+});
+
+const LockItem = GObject.registerClass(
+class LockItem extends QuickSettingsItem {
+ _init() {
+ this._systemActions = new SystemActions.getDefault();
+
+ super._init({
+ style_class: 'icon-button',
+ can_focus: true,
+ icon_name: 'system-lock-screen-symbolic',
+ accessible_name: C_('action', 'Lock Screen'),
+ });
+
+ this._systemActions.bind_property('can-lock-screen',
+ this, 'visible',
+ GObject.BindingFlags.DEFAULT |
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.connect('clicked',
+ () => this._systemActions.activateLockScreen());
+ }
+});
+
+
+const SystemItem = GObject.registerClass(
+class SystemItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'quick-settings-system-item',
+ reactive: false,
+ });
+
+ this.child = new St.BoxLayout();
+
+ this._powerToggle = new PowerToggle();
+ this.child.add_child(this._powerToggle);
+
+ this._laptopSpacer = new Clutter.Actor({x_expand: true});
+ this._powerToggle.bind_property('visible',
+ this._laptopSpacer, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.child.add_child(this._laptopSpacer);
+
+ const screenshotItem = new ScreenshotItem();
+ this.child.add_child(screenshotItem);
+
+ const settingsItem = new SettingsItem();
+ this.child.add_child(settingsItem);
+
+ this._desktopSpacer = new Clutter.Actor({x_expand: true});
+ this._powerToggle.bind_property('visible',
+ this._desktopSpacer, 'visible',
+ GObject.BindingFlags.INVERT_BOOLEAN |
+ GObject.BindingFlags.SYNC_CREATE);
+ this.child.add_child(this._desktopSpacer);
+
+ const lockItem = new LockItem();
+ this.child.add_child(lockItem);
+
+ const shutdownItem = new ShutdownItem();
+ this.child.add_child(shutdownItem);
+
+ this.menu = shutdownItem.menu;
+ }
+
+ get powerToggle() {
+ return this._powerToggle;
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._desktopSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.interface',
+ });
+ this._desktopSettings.connectObject(
+ `changed::${SHOW_BATTERY_PERCENTAGE}`, () => this._sync(), 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._systemItem = new SystemItem();
+
+ const {powerToggle} = this._systemItem;
+
+ powerToggle.bind_property('label',
+ this._percentageLabel, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ powerToggle.connectObject(
+ 'notify::visible', () => this._sync(),
+ 'notify::gicon', () => this._sync(),
+ 'notify::fallback-icon-name', () => this._sync(),
+ this);
+
+ this.quickSettingsItems.push(this._systemItem);
+
+ this._sync();
+ }
+
+ _sync() {
+ const {powerToggle} = this._systemItem;
+ if (powerToggle.visible) {
+ this._indicator.set({
+ gicon: powerToggle.gicon,
+ fallback_icon_name: powerToggle.fallback_icon_name,
+ });
+ this._percentageLabel.visible =
+ this._desktopSettings.get_boolean(SHOW_BATTERY_PERCENTAGE);
+ } else {
+ // If there's no battery, then we use the power icon.
+ this._indicator.icon_name = 'system-shutdown-symbolic';
+ this._percentageLabel.hide();
+ }
+ }
+});
diff --git a/js/ui/status/thunderbolt.js b/js/ui/status/thunderbolt.js
new file mode 100644
index 0000000..2e1236e
--- /dev/null
+++ b/js/ui/status/thunderbolt.js
@@ -0,0 +1,332 @@
+// -*- 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.misc.signals;
+
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const {SystemIndicator} = imports.ui.quickSettings;
+
+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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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: ${e.message}`);
+ return;
+ }
+ this._proxy.connectObject('g-properties-changed',
+ this._onPropertiesChanged.bind(this), 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) {
+ const probingChanged = !!properties.lookup_value('Probing', null);
+ if (probingChanged) {
+ 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.disconnectObject(this);
+ this._proxy = null;
+ }
+
+ async enrollDevice(id, policy) {
+ try {
+ const [path] = await this._proxy.EnrollDeviceAsync(id, policy, AuthCtrl.NONE);
+ const device = new BoltDeviceProxy(Gio.DBus.system, BOLT_DBUS_NAME, path);
+ return device;
+ } catch (error) {
+ Gio.DBusError.strip_remote_error(error);
+ throw error;
+ }
+ }
+
+ get authMode() {
+ return this._proxy.AuthMode;
+ }
+};
+
+/* helper class to automatically authorize new devices */
+var AuthRobot = class extends Signals.EventEmitter {
+ constructor(client) {
+ super();
+
+ 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));
+ }
+
+ async _enrollDevicesIdle() {
+ let devices = this._devicesToEnroll;
+
+ let dev = devices.shift();
+ if (dev === undefined)
+ return GLib.SOURCE_REMOVE;
+
+ try {
+ await this._client.enrollDevice(dev.Uid, Policy.DEFAULT);
+
+ /* 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));
+ }
+ } catch (error) {
+ this.emit('enroll-failed', null, error);
+ }
+ return GLib.SOURCE_REMOVE;
+ }
+};
+
+/* eof client.js */
+
+var Indicator = GObject.registerClass(
+class Indicator extends 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: ${e}`);
+ }
+ }
+
+ _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: [${device.Name}] auto enrollment: ${auth ? 'yes' : 'no'} (allowed: ${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..bd49cc3
--- /dev/null
+++ b/js/ui/status/volume.js
@@ -0,0 +1,458 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Clutter, Gio, GLib, GObject, Gvc} = imports.gi;
+
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+
+const {QuickSlider, SystemIndicator} = imports.ui.quickSettings;
+
+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;
+/**
+ * @returns {Gvc.MixerControl} - the mixer control singleton
+ */
+function getMixerControl() {
+ if (_mixerControl)
+ return _mixerControl;
+
+ _mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' });
+ _mixerControl.open();
+
+ return _mixerControl;
+}
+
+const StreamSlider = GObject.registerClass({
+ Signals: {
+ 'stream-updated': {},
+ },
+}, class StreamSlider extends QuickSlider {
+ _init(control) {
+ super._init();
+
+ this._control = control;
+
+ this._inDrag = false;
+ this._notifyVolumeChangeId = 0;
+
+ this._soundSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.sound',
+ });
+ this._soundSettings.connect(`changed::${ALLOW_AMPLIFIED_VOLUME_KEY}`,
+ () => this._amplifySettingsChanged());
+ this._amplifySettingsChanged();
+
+ this._sliderChangedId = this.slider.connect('notify::value',
+ () => this._sliderChanged());
+ this.slider.connect('drag-begin', () => (this._inDrag = true));
+ this.slider.connect('drag-end', () => {
+ this._inDrag = false;
+ this._notifyVolumeChange();
+ });
+
+ this._deviceItems = new Map();
+
+ this._deviceSection = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(this._deviceSection);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this.menu.addSettingsAction(_('Sound Settings'),
+ 'gnome-sound-panel.desktop');
+
+ this._stream = null;
+ this._volumeCancellable = null;
+ this._icons = [];
+
+ this._sync();
+ }
+
+ get stream() {
+ return this._stream;
+ }
+
+ set stream(stream) {
+ this._stream?.disconnectObject(this);
+
+ this._stream = stream;
+
+ if (this._stream) {
+ this._connectStream(this._stream);
+ this._updateVolume();
+ } else {
+ this.emit('stream-updated');
+ }
+
+ this._sync();
+ }
+
+ _connectStream(stream) {
+ stream.connectObject(
+ 'notify::is-muted', this._updateVolume.bind(this),
+ 'notify::volume', this._updateVolume.bind(this), this);
+ }
+
+ _lookupDevice(_id) {
+ throw new GObject.NotImplementedError(
+ `_lookupDevice in ${this.constructor.name}`);
+ }
+
+ _activateDevice(_device) {
+ throw new GObject.NotImplementedError(
+ `_activateDevice in ${this.constructor.name}`);
+ }
+
+ _addDevice(id) {
+ if (this._deviceItems.has(id))
+ return;
+
+ const device = this._lookupDevice(id);
+ if (!device)
+ return;
+
+ const {description, origin} = device;
+ const name = origin
+ ? `${description} – ${origin}`
+ : description;
+ const item = new PopupMenu.PopupImageMenuItem(name, device.get_gicon());
+ item.connect('activate', () => this._activateDevice(device));
+
+ this._deviceSection.addMenuItem(item);
+ this._deviceItems.set(id, item);
+
+ this._sync();
+ }
+
+ _removeDevice(id) {
+ this._deviceItems.get(id)?.destroy();
+ if (this._deviceItems.delete(id))
+ this._sync();
+ }
+
+ _setActiveDevice(activeId) {
+ for (const [id, item] of this._deviceItems) {
+ item.setOrnament(id === activeId
+ ? PopupMenu.Ornament.CHECK
+ : PopupMenu.Ornament.NONE);
+ }
+ }
+
+ _shouldBeVisible() {
+ return this._stream != null;
+ }
+
+ _sync() {
+ this.visible = this._shouldBeVisible();
+ this.menuEnabled = this._deviceItems.size > 1;
+ }
+
+ _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();
+ }
+});
+
+const OutputStreamSlider = GObject.registerClass(
+class OutputStreamSlider extends StreamSlider {
+ _init(control) {
+ super._init(control);
+
+ this.slider.accessible_name = _('Volume');
+
+ this._control.connectObject(
+ 'output-added', (c, id) => this._addDevice(id),
+ 'output-removed', (c, id) => this._removeDevice(id),
+ 'active-output-update', (c, id) => this._setActiveDevice(id),
+ this);
+
+ this._icons = [
+ 'audio-volume-muted-symbolic',
+ 'audio-volume-low-symbolic',
+ 'audio-volume-medium-symbolic',
+ 'audio-volume-high-symbolic',
+ 'audio-volume-overamplified-symbolic',
+ ];
+
+ this.menu.setHeader('audio-headphones-symbolic', _('Sound Output'));
+ }
+
+ _connectStream(stream) {
+ super._connectStream(stream);
+ stream.connectObject('notify::port',
+ this._portChanged.bind(this), this);
+ this._portChanged();
+ }
+
+ _lookupDevice(id) {
+ return this._control.lookup_output_id(id);
+ }
+
+ _activateDevice(device) {
+ this._control.change_output(device);
+ }
+
+ _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;
+ }
+
+ _portChanged() {
+ const hasHeadphones = this._findHeadphones(this._stream);
+ if (hasHeadphones === this._hasHeadphones)
+ return;
+
+ this._hasHeadphones = hasHeadphones;
+ this.iconName = this._hasHeadphones
+ ? 'audio-headphones-symbolic'
+ : 'audio-speakers-symbolic';
+ }
+});
+
+const InputStreamSlider = GObject.registerClass(
+class InputStreamSlider extends StreamSlider {
+ _init(control) {
+ super._init(control);
+
+ this.slider.accessible_name = _('Microphone');
+
+ this._control.connectObject(
+ 'input-added', (c, id) => this._addDevice(id),
+ 'input-removed', (c, id) => this._removeDevice(id),
+ 'active-input-update', (c, id) => this._setActiveDevice(id),
+ 'stream-added', () => this._maybeShowInput(),
+ 'stream-removed', () => this._maybeShowInput(),
+ this);
+
+ this.iconName = 'audio-input-microphone-symbolic';
+ this._icons = [
+ 'microphone-sensitivity-muted-symbolic',
+ 'microphone-sensitivity-low-symbolic',
+ 'microphone-sensitivity-medium-symbolic',
+ 'microphone-sensitivity-high-symbolic',
+ ];
+
+ this.menu.setHeader('audio-input-microphone-symbolic', _('Sound Input'));
+ }
+
+ _connectStream(stream) {
+ super._connectStream(stream);
+ this._maybeShowInput();
+ }
+
+ _lookupDevice(id) {
+ return this._control.lookup_input_id(id);
+ }
+
+ _activateDevice(device) {
+ this._control.change_input(device);
+ }
+
+ _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 => !skippedApps.includes(output.get_application_id()));
+ }
+
+ this._showInput = showInput;
+ this._sync();
+ }
+
+ _shouldBeVisible() {
+ return super._shouldBeVisible() && this._showInput;
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._primaryIndicator = this._addIndicator();
+ this._inputIndicator = this._addIndicator();
+
+ this._primaryIndicator.reactive = true;
+ this._inputIndicator.reactive = true;
+
+ this._primaryIndicator.connect('scroll-event',
+ (actor, event) => this._handleScrollEvent(this._output, event));
+ this._inputIndicator.connect('scroll-event',
+ (actor, event) => this._handleScrollEvent(this._input, event));
+
+ this._control = getMixerControl();
+ this._control.connectObject(
+ 'state-changed', () => this._onControlStateChanged(),
+ 'default-sink-changed', () => this._readOutput(),
+ 'default-source-changed', () => this._readInput(),
+ this);
+
+ this._output = new OutputStreamSlider(this._control);
+ this._output.connect('stream-updated', () => {
+ const icon = this._output.getIcon();
+
+ if (icon)
+ this._primaryIndicator.icon_name = icon;
+ this._primaryIndicator.visible = icon !== null;
+ });
+
+ this._input = new InputStreamSlider(this._control);
+ this._input.connect('stream-updated', () => {
+ const icon = this._input.getIcon();
+
+ if (icon)
+ this._inputIndicator.icon_name = icon;
+ });
+
+ this._input.bind_property('visible',
+ this._inputIndicator, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.quickSettingsItems.push(this._output);
+ this.quickSettingsItems.push(this._input);
+
+ this._onControlStateChanged();
+ }
+
+ _onControlStateChanged() {
+ if (this._control.get_state() === Gvc.MixerControlState.READY) {
+ this._readInput();
+ this._readOutput();
+ } else {
+ this._primaryIndicator.hide();
+ }
+ }
+
+ _readOutput() {
+ this._output.stream = this._control.get_default_sink();
+ }
+
+ _readInput() {
+ this._input.stream = this._control.get_default_source();
+ }
+
+ _handleScrollEvent(item, event) {
+ const result = item.slider.scroll(event);
+ if (result === Clutter.EVENT_PROPAGATE || item.mapped)
+ return result;
+
+ const gicon = new Gio.ThemedIcon({name: item.getIcon()});
+ const level = item.getLevel();
+ const maxLevel = item.getMaxLevel();
+ Main.osdWindowManager.show(-1, gicon, null, level, maxLevel);
+ return result;
+ }
+});