gnome-shell/js/ui/status/bluetooth.js
Florian Müllner 8c57eab5e6 status/bluetooth: Only show when rfkill is available
We currently show the bluetooth toggle when Bluetooth can be
toggled via rfkill, or when there is a powered adapter.

While the latter condition is obvious - if there is a working
Bluetooth adapter, then Bluetooth is available - it does impose
a problem: We rely on rfkill for turning Bluetooth off, so if
rfkill is missing, the toggle is stuck.

We could handle that case and power off the adapter ourselves
when necessary, but then the toggle would just disappear when
turned off.

Instead, only show the toggle when rfkill is available, so we
can assume that turning Bluetooth on and off will work.

This is also consistent with Settings, which shows Bluetooth
as unavailable in this case.

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2815>
2023-09-01 11:37:04 +00:00

418 lines
14 KiB
JavaScript

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
import 'gi://GnomeBluetooth?version=3.0';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GnomeBluetooth from 'gi://GnomeBluetooth';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import St from 'gi://St';
import {Spinner} from '../animation.js';
import * as PopupMenu from '../popupMenu.js';
import {QuickMenuToggle, SystemIndicator} from '../quickSettings.js';
import {loadInterfaceXML} from '../../misc/fileUtils.js';
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);
Gio._promisify(GnomeBluetooth.Client.prototype, 'connect_service');
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': {},
'device-removed': {param_types: [GObject.TYPE_STRING]},
},
}, class BtClient extends GObject.Object {
_init() {
super._init();
this._client = new GnomeBluetooth.Client();
this._client.connect('notify::default-adapter-powered', () => {
this.notify('active');
});
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._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('device-removed', 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;
}
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;
}
async toggleDevice(device) {
const connect = !device.connected;
console.debug(`${connect
? 'Connect' : 'Disconnect'} device "${device.name}"`);
try {
await this._client.connect_service(
device.get_object_path(),
connect,
null);
console.debug(`Device "${device.name}" ${
connect ? 'connected' : 'disconnected'}`);
} catch (e) {
console.error(`Failed to ${connect
? 'connect' : 'disconnect'} device "${device.name}": ${e.message}`);
}
}
*getDevices() {
// Ignore any lingering device references when turned off
if (!this.active)
return;
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 BluetoothDeviceItem = GObject.registerClass(
class BluetoothDeviceItem extends PopupMenu.PopupBaseMenuItem {
constructor(device, client) {
super({
style_class: 'bt-device-item',
});
this._device = device;
this._client = client;
this._icon = new St.Icon({
style_class: 'popup-menu-icon',
});
this.add_child(this._icon);
this._label = new St.Label({
x_expand: true,
});
this.add_child(this._label);
this._subtitle = new St.Label({
style_class: 'device-subtitle',
});
this.add_child(this._subtitle);
this._spinner = new Spinner(16, {hideOnStop: true});
this.add_child(this._spinner);
this._spinner.bind_property('visible',
this._subtitle, 'visible',
GObject.BindingFlags.SYNC_CREATE |
GObject.BindingFlags.INVERT_BOOLEAN);
this._device.bind_property('connectable',
this, 'visible',
GObject.BindingFlags.SYNC_CREATE);
this._device.bind_property('icon',
this._icon, 'icon-name',
GObject.BindingFlags.SYNC_CREATE);
this._device.bind_property('alias',
this._label, 'text',
GObject.BindingFlags.SYNC_CREATE);
this._device.bind_property_full('connected',
this._subtitle, 'text',
GObject.BindingFlags.SYNC_CREATE,
(bind, source) => [true, source ? _('Disconnect') : _('Connect')],
null);
this.connect('destroy', () => (this._spinner = null));
this.connect('activate', () => this._toggleConnected().catch(logError));
}
async _toggleConnected() {
this._spinner.play();
await this._client.toggleDevice(this._device);
this._spinner?.stop();
}
});
const BluetoothToggle = GObject.registerClass(
class BluetoothToggle extends QuickMenuToggle {
_init(client) {
super._init({title: _('Bluetooth')});
this.menu.setHeader('bluetooth-active-symbolic', _('Bluetooth'));
this._deviceItems = new Map();
this._deviceSection = new PopupMenu.PopupMenuSection();
this.menu.addMenuItem(this._deviceSection);
this._placeholderItem = new PopupMenu.PopupMenuItem('', {
style_class: 'bt-menu-placeholder',
reactive: false,
can_focus: false,
});
this._placeholderItem.label.clutter_text.set({
ellipsize: Pango.EllipsizeMode.NONE,
line_wrap: true,
});
this.menu.addMenuItem(this._placeholderItem);
this._deviceSection.actor.bind_property('visible',
this._placeholderItem, 'visible',
GObject.BindingFlags.SYNC_CREATE |
GObject.BindingFlags.INVERT_BOOLEAN);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this.menu.addSettingsAction(_('Bluetooth Settings'),
'gnome-bluetooth-panel.desktop');
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._client.connectObject(
'notify::active', () => this._onActiveChanged(),
'devices-changed', () => this._sync(),
'device-removed', (c, path) => this._removeDevice(path),
this);
this.menu.connect('open-state-changed', isOpen => {
// We don't reorder the list while the menu is open,
// so do it now to start with the proper order
if (isOpen)
this._reorderDeviceItems();
});
this.connect('clicked', () => this._client.toggleActive());
this._updatePlaceholder();
this._sync();
}
_onActiveChanged() {
this._updatePlaceholder();
this._deviceItems.forEach(item => item.destroy());
this._deviceItems.clear();
this._sync();
}
_updatePlaceholder() {
this._placeholderItem.label.text = this._client.active
? _('No available or connected devices')
: _('Turn on Bluetooth to connect to devices');
}
_updateDeviceVisibility() {
this._deviceSection.actor.visible =
[...this._deviceItems.values()].some(item => item.visible);
}
_getSortedDevices() {
return [...this._client.getDevices()].sort((dev1, dev2) => {
if (dev1.connected !== dev2.connected)
return dev2.connected - dev1.connected;
return dev1.alias.localeCompare(dev2.alias);
});
}
_removeDevice(path) {
this._deviceItems.get(path)?.destroy();
this._deviceItems.delete(path);
this._updateDeviceVisibility();
}
_reorderDeviceItems() {
const devices = this._getSortedDevices();
for (const [i, dev] of devices.entries()) {
const item = this._deviceItems.get(dev.get_object_path());
if (!item)
continue;
this._deviceSection.moveMenuItem(item, i);
}
}
_sync() {
const devices = this._getSortedDevices();
for (const dev of devices) {
const path = dev.get_object_path();
if (this._deviceItems.has(path))
continue;
const item = new BluetoothDeviceItem(dev, this._client);
item.connect('notify::visible', () => this._updateDeviceVisibility());
this._deviceSection.addMenuItem(item);
this._deviceItems.set(path, item);
}
const connectedDevices = devices.filter(dev => dev.connected);
const nConnected = connectedDevices.length;
if (nConnected > 1)
/* Translators: This is the number of connected bluetooth devices */
this.subtitle = ngettext('%d Connected', '%d Connected', nConnected).format(nConnected);
else if (nConnected === 1)
this.subtitle = connectedDevices[0].alias;
else
this.subtitle = null;
this._updateDeviceVisibility();
}
_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 '';
}
}
});
export const 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;
}
});