8c57eab5e6
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>
418 lines
14 KiB
JavaScript
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;
|
|
}
|
|
});
|