diff options
Diffstat (limited to '')
-rw-r--r-- | js/ui/status/accessibility.js | 153 | ||||
-rw-r--r-- | js/ui/status/autoRotate.js | 45 | ||||
-rw-r--r-- | js/ui/status/bluetooth.js | 211 | ||||
-rw-r--r-- | js/ui/status/brightness.js | 64 | ||||
-rw-r--r-- | js/ui/status/darkMode.js | 49 | ||||
-rw-r--r-- | js/ui/status/dwellClick.js | 83 | ||||
-rw-r--r-- | js/ui/status/keyboard.js | 1095 | ||||
-rw-r--r-- | js/ui/status/location.js | 371 | ||||
-rw-r--r-- | js/ui/status/network.js | 2095 | ||||
-rw-r--r-- | js/ui/status/nightLight.js | 70 | ||||
-rw-r--r-- | js/ui/status/powerProfiles.js | 126 | ||||
-rw-r--r-- | js/ui/status/remoteAccess.js | 230 | ||||
-rw-r--r-- | js/ui/status/rfkill.js | 136 | ||||
-rw-r--r-- | js/ui/status/system.js | 348 | ||||
-rw-r--r-- | js/ui/status/thunderbolt.js | 332 | ||||
-rw-r--r-- | js/ui/status/volume.js | 458 |
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; + } +}); |