diff options
Diffstat (limited to 'js/ui/status/network.js')
-rw-r--r-- | js/ui/status/network.js | 2095 |
1 files changed, 2095 insertions, 0 deletions
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; + } +}); |