516 lines
14 KiB
JavaScript
Raw Normal View History

2024-07-08 22:46:35 +02:00
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../config.js';
import * as DBus from './utils/dbus.js';
import Device from './device.js';
import * as LanBackend from './backends/lan.js';
const DEVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect.Device';
const DEVICE_PATH = '/org/gnome/Shell/Extensions/GSConnect/Device';
const DEVICE_IFACE = Config.DBUS.lookup_interface(DEVICE_NAME);
const backends = {
lan: LanBackend,
};
/**
* A manager for devices.
*/
const Manager = GObject.registerClass({
GTypeName: 'GSConnectManager',
Properties: {
'active': GObject.ParamSpec.boolean(
'active',
'Active',
'Whether the manager is active',
GObject.ParamFlags.READABLE,
false
),
'discoverable': GObject.ParamSpec.boolean(
'discoverable',
'Discoverable',
'Whether the service responds to discovery requests',
GObject.ParamFlags.READWRITE,
false
),
'id': GObject.ParamSpec.string(
'id',
'Id',
'The hostname or other network unique id',
GObject.ParamFlags.READWRITE,
null
),
'name': GObject.ParamSpec.string(
'name',
'Name',
'The name announced to the network',
GObject.ParamFlags.READWRITE,
'GSConnect'
),
},
}, class Manager extends Gio.DBusObjectManagerServer {
_init(params = {}) {
super._init(params);
this._exported = new WeakMap();
this._reconnectId = 0;
this._settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
});
this._initSettings();
}
get active() {
if (this._active === undefined)
this._active = false;
return this._active;
}
get backends() {
if (this._backends === undefined)
this._backends = new Map();
return this._backends;
}
get devices() {
if (this._devices === undefined)
this._devices = new Map();
return this._devices;
}
get discoverable() {
if (this._discoverable === undefined)
this._discoverable = this.settings.get_boolean('discoverable');
return this._discoverable;
}
set discoverable(value) {
if (this.discoverable === value)
return;
this._discoverable = value;
this.notify('discoverable');
// FIXME: This whole thing just keeps getting uglier
const application = Gio.Application.get_default();
if (application === null)
return;
if (this.discoverable) {
Gio.Application.prototype.withdraw_notification.call(
application,
'discovery-warning'
);
} else {
const notif = new Gio.Notification();
notif.set_title(_('Discovery Disabled'));
notif.set_body(_('Discovery has been disabled due to the number of devices on this network.'));
notif.set_icon(new Gio.ThemedIcon({name: 'dialog-warning'}));
notif.set_priority(Gio.NotificationPriority.HIGH);
notif.set_default_action('app.preferences');
Gio.Application.prototype.withdraw_notification.call(
application,
'discovery-warning',
notif
);
}
}
get id() {
if (this._id === undefined)
this._id = this.settings.get_string('id');
return this._id;
}
set id(value) {
if (this.id === value)
return;
this._id = value;
this.notify('id');
}
get name() {
if (this._name === undefined)
this._name = this.settings.get_string('name');
return this._name;
}
set name(value) {
if (this.name === value)
return;
this._name = value;
this.notify('name');
// Broadcast changes to the network
for (const backend of this.backends.values()) {
backend.name = this.name;
backend.buildIdentity();
}
this.identify();
}
get settings() {
if (this._settings === undefined) {
this._settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
});
}
return this._settings;
}
vfunc_notify(pspec) {
if (pspec.name !== 'connection')
return;
if (this.connection !== null)
this._exportDevices();
else
this._unexportDevices();
}
/*
* GSettings
*/
_initSettings() {
// Initialize the ID and name of the service
if (this.settings.get_string('id').length === 0)
this.settings.set_string('id', GLib.uuid_string_random());
if (this.settings.get_string('name').length === 0)
this.settings.set_string('name', GLib.get_host_name());
// Bound Properties
this.settings.bind('discoverable', this, 'discoverable', 0);
this.settings.bind('id', this, 'id', 0);
this.settings.bind('name', this, 'name', 0);
}
/*
* Backends
*/
_onChannel(backend, channel) {
try {
let device = this.devices.get(channel.identity.body.deviceId);
switch (true) {
// Proceed if this is an existing device...
case (device !== undefined):
break;
// Or the connection is allowed...
case this.discoverable || channel.allowed:
device = this._ensureDevice(channel.identity);
break;
// ...otherwise bail
default:
debug(`${channel.identity.body.deviceName}: not allowed`);
return false;
}
device.setChannel(channel);
return true;
} catch (e) {
logError(e, backend.name);
return false;
}
}
_loadBackends() {
for (const name in backends) {
try {
const module = backends[name];
if (module.ChannelService === undefined)
continue;
// Try to create the backend and track it if successful
const backend = new module.ChannelService({
id: this.id,
name: this.name,
});
this.backends.set(name, backend);
// Connect to the backend
backend.__channelId = backend.connect(
'channel',
this._onChannel.bind(this)
);
// Now try to start the backend, allowing us to retry if we fail
backend.start();
} catch (e) {
if (Gio.Application.get_default())
Gio.Application.get_default().notify_error(e);
}
}
}
/*
* Devices
*/
_loadDevices() {
// Load cached devices
for (const id of this.settings.get_strv('devices')) {
const device = new Device({body: {deviceId: id}});
this._exportDevice(device);
this.devices.set(id, device);
}
}
_exportDevice(device) {
if (this.connection === null)
return;
const info = {
object: null,
interface: null,
actions: 0,
menu: 0,
};
const objectPath = `${DEVICE_PATH}/${device.id.replace(/\W+/g, '_')}`;
// Export an object path for the device
info.object = new Gio.DBusObjectSkeleton({
g_object_path: objectPath,
});
this.export(info.object);
// Export GActions & GMenu
info.actions = Gio.DBus.session.export_action_group(objectPath, device);
info.menu = Gio.DBus.session.export_menu_model(objectPath, device.menu);
// Export the Device interface
info.interface = new DBus.Interface({
g_instance: device,
g_interface_info: DEVICE_IFACE,
});
info.object.add_interface(info.interface);
this._exported.set(device, info);
}
_exportDevices() {
if (this.connection === null)
return;
for (const device of this.devices.values())
this._exportDevice(device);
}
_unexportDevice(device) {
const info = this._exported.get(device);
if (info === undefined)
return;
// Unexport GActions and GMenu
Gio.DBus.session.unexport_action_group(info.actions);
Gio.DBus.session.unexport_menu_model(info.menu);
// Unexport the Device interface and object
info.interface.flush();
info.object.remove_interface(info.interface);
info.object.flush();
this.unexport(info.object.g_object_path);
this._exported.delete(device);
}
_unexportDevices() {
for (const device of this.devices.values())
this._unexportDevice(device);
}
/**
* Return a device for @packet, creating it and adding it to the list of
* of known devices if it doesn't exist.
*
* @param {Core.Packet} packet - An identity packet for the device
* @return {Device} A device object
*/
_ensureDevice(packet) {
let device = this.devices.get(packet.body.deviceId);
if (device === undefined) {
debug(`Adding ${packet.body.deviceName}`);
// TODO: Remove when all clients support bluetooth-like discovery
//
// If this is the third unpaired device to connect, we disable
// discovery to avoid choking on networks with many devices
const unpaired = Array.from(this.devices.values()).filter(dev => {
return !dev.paired;
});
if (unpaired.length === 3)
this.discoverable = false;
device = new Device(packet);
this._exportDevice(device);
this.devices.set(device.id, device);
// Notify
this.settings.set_strv('devices', Array.from(this.devices.keys()));
}
return device;
}
/**
* Permanently remove a device.
*
* Removes the device from the list of known devices, deletes all GSettings
* and files.
*
* @param {string} id - The id of the device to delete
*/
_removeDevice(id) {
// Delete all GSettings
const settings_path = `/org/gnome/shell/extensions/gsconnect/${id}/`;
GLib.spawn_command_line_async(`dconf reset -f ${settings_path}`);
// Delete the cache
const cache = GLib.build_filenamev([Config.CACHEDIR, id]);
Gio.File.rm_rf(cache);
// Forget the device
this.devices.delete(id);
this.settings.set_strv('devices', Array.from(this.devices.keys()));
}
/**
* A GSourceFunc that tries to reconnect to each paired device, while
* pruning unpaired devices that have disconnected.
*
* @return {boolean} Always %true
*/
_reconnect() {
for (const [id, device] of this.devices) {
if (device.connected)
continue;
if (device.paired) {
this.identify(device.settings.get_string('last-connection'));
continue;
}
this._unexportDevice(device);
this._removeDevice(id);
device.destroy();
}
return GLib.SOURCE_CONTINUE;
}
/**
* Identify to an address or broadcast to the network.
*
* @param {string} [uri] - An address URI or %null to broadcast
*/
identify(uri = null) {
try {
// If we're passed a parameter, try and find a backend for it
if (uri !== null) {
const [scheme, address] = uri.split('://');
const backend = this.backends.get(scheme);
if (backend !== undefined)
backend.broadcast(address);
// If we're not discoverable, only try to reconnect known devices
} else if (!this.discoverable) {
this._reconnect();
// Otherwise have each backend broadcast to it's network
} else {
this.backends.forEach(backend => backend.broadcast());
}
} catch (e) {
logError(e);
}
}
/**
* Start managing devices.
*/
start() {
if (this.active)
return;
this._loadDevices();
this._loadBackends();
if (this._reconnectId === 0) {
this._reconnectId = GLib.timeout_add_seconds(
GLib.PRIORITY_LOW,
5,
this._reconnect.bind(this)
);
}
this._active = true;
this.notify('active');
}
/**
* Stop managing devices.
*/
stop() {
if (!this.active)
return;
if (this._reconnectId > 0) {
GLib.Source.remove(this._reconnectId);
this._reconnectId = 0;
}
this._unexportDevices();
this.backends.forEach(backend => backend.destroy());
this.backends.clear();
this.devices.forEach(device => device.destroy());
this.devices.clear();
this._active = false;
this.notify('active');
}
/**
* Stop managing devices and free any resources.
*/
destroy() {
this.stop();
this.set_connection(null);
}
});
export default Manager;