// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js'; // Bootstrap import { Extension, gettext as _, ngettext } from 'resource:///org/gnome/shell/extensions/extension.js'; import Config from './config.js'; import * as Clipboard from './shell/clipboard.js'; import * as Device from './shell/device.js'; import * as Keybindings from './shell/keybindings.js'; import * as Notification from './shell/notification.js'; import * as Input from './shell/input.js'; import * as Utils from './shell/utils.js'; import * as Remote from './utils/remote.js'; import setup from './utils/setup.js'; const QuickSettingsMenu = Main.panel.statusArea.quickSettings; /** * A System Indicator used as the hub for spawning device indicators and * indicating that the extension is active when there are none. */ const ServiceToggle = GObject.registerClass({ GTypeName: 'GSConnectServiceIndicator', }, class ServiceToggle extends QuickSettings.QuickMenuToggle { _init() { super._init({ title: 'GSConnect', toggleMode: true, }); this.set({iconName: 'org.gnome.Shell.Extensions.GSConnect-symbolic'}); // Set QuickMenuToggle header. this.menu.setHeader('org.gnome.Shell.Extensions.GSConnect-symbolic', 'GSConnect', _('Sync between your devices')); this._menus = {}; this._keybindings = new Keybindings.Manager(); // GSettings this.settings = new Gio.Settings({ settings_schema: Config.GSCHEMA.lookup( 'org.gnome.Shell.Extensions.GSConnect', null ), path: '/org/gnome/shell/extensions/gsconnect/', }); // Bind the toggle to enabled key this.settings.bind('enabled', this, 'checked', Gio.SettingsBindFlags.DEFAULT); this._enabledId = this.settings.connect( 'changed::enabled', this._onEnabledChanged.bind(this) ); this._panelModeId = this.settings.connect( 'changed::show-indicators', this._sync.bind(this) ); // Service Proxy this.service = new Remote.Service(); this._deviceAddedId = this.service.connect( 'device-added', this._onDeviceAdded.bind(this) ); this._deviceRemovedId = this.service.connect( 'device-removed', this._onDeviceRemoved.bind(this) ); this._serviceChangedId = this.service.connect( 'notify::active', this._onServiceChanged.bind(this) ); // Service Menu -> Devices Section this.deviceSection = new PopupMenu.PopupMenuSection(); this.deviceSection.actor.add_style_class_name('gsconnect-device-section'); this.settings.bind( 'show-indicators', this.deviceSection.actor, 'visible', Gio.SettingsBindFlags.INVERT_BOOLEAN ); this.menu.addMenuItem(this.deviceSection); // Service Menu -> Separator this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); // Service Menu -> "Mobile Settings" this.menu.addSettingsAction( _('Mobile Settings'), 'org.gnome.Shell.Extensions.GSConnect.Preferences.desktop'); // Prime the service this._initService(); } async _initService() { try { if (this.settings.get_boolean('enabled')) await this.service.start(); else await this.service.reload(); } catch (e) { logError(e, 'GSConnect'); } } _sync() { const available = this.service.devices.filter(device => { return (device.connected && device.paired); }); const panelMode = this.settings.get_boolean('show-indicators'); // Hide status indicator if in Panel mode or no devices are available serviceIndicator._indicator.visible = (!panelMode && available.length); // Show device indicators in Panel mode if available for (const device of this.service.devices) { const isAvailable = available.includes(device); const indicator = Main.panel.statusArea[device.g_object_path]; indicator.visible = panelMode && isAvailable; const menu = this._menus[device.g_object_path]; menu.actor.visible = !panelMode && isAvailable; menu._title.actor.visible = !panelMode && isAvailable; } // Set subtitle on Quick Settings tile if (available.length === 1) { this.subtitle = available[0].name; } else if (available.length > 1) { // TRANSLATORS: %d is the number of devices connected this.subtitle = ngettext( '%d Connected', '%d Connected', available.length ).format(available.length); } else { this.subtitle = null; } } _onDeviceChanged(device, changed, invalidated) { try { const properties = changed.deepUnpack(); if (properties.hasOwnProperty('Connected') || properties.hasOwnProperty('Paired')) this._sync(); } catch (e) { logError(e, 'GSConnect'); } } _onDeviceAdded(service, device) { try { // Device Indicator const indicator = new Device.Indicator({device: device}); Main.panel.addToStatusArea(device.g_object_path, indicator); // Device Menu const menu = new Device.Menu({ device: device, menu_type: 'list', }); this._menus[device.g_object_path] = menu; this.deviceSection.addMenuItem(menu); // Device Settings device.settings = new Gio.Settings({ settings_schema: Config.GSCHEMA.lookup( 'org.gnome.Shell.Extensions.GSConnect.Device', true ), path: `/org/gnome/shell/extensions/gsconnect/device/${device.id}/`, }); // Keyboard Shortcuts device.__keybindingsChangedId = device.settings.connect( 'changed::keybindings', this._onDeviceKeybindingsChanged.bind(this, device) ); this._onDeviceKeybindingsChanged(device); // Watch the for status changes device.__deviceChangedId = device.connect( 'g-properties-changed', this._onDeviceChanged.bind(this) ); this._sync(); } catch (e) { logError(e, 'GSConnect'); } } _onDeviceRemoved(service, device, sync = true) { try { // Stop watching for status changes if (device.__deviceChangedId) device.disconnect(device.__deviceChangedId); // Release keybindings if (device.__keybindingsChangedId) { device.settings.disconnect(device.__keybindingsChangedId); device._keybindings.map(id => this._keybindings.remove(id)); } // Destroy the indicator Main.panel.statusArea[device.g_object_path].destroy(); // Destroy the menu this._menus[device.g_object_path].destroy(); delete this._menus[device.g_object_path]; if (sync) this._sync(); } catch (e) { logError(e, 'GSConnect'); } } _onDeviceKeybindingsChanged(device) { try { // Reset any existing keybindings if (device.hasOwnProperty('_keybindings')) device._keybindings.map(id => this._keybindings.remove(id)); device._keybindings = []; // Get the keybindings const keybindings = device.settings.get_value('keybindings').deepUnpack(); // Apply the keybindings for (const [action, accelerator] of Object.entries(keybindings)) { const [, name, parameter] = Gio.Action.parse_detailed_name(action); const actionId = this._keybindings.add( accelerator, () => device.action_group.activate_action(name, parameter) ); if (actionId !== 0) device._keybindings.push(actionId); } } catch (e) { logError(e, 'GSConnect'); } } async _onEnabledChanged(settings, key) { try { if (this.settings.get_boolean('enabled')) await this.service.start(); else await this.service.stop(); } catch (e) { logError(e, 'GSConnect'); } } async _onServiceChanged(service, pspec) { try { // If it's enabled, we should try to restart now if (this.settings.get_boolean('enabled')) await this.service.start(); } catch (e) { logError(e, 'GSConnect'); } } destroy() { // Unhook from Remote.Service if (this.service) { this.service.disconnect(this._serviceChangedId); this.service.disconnect(this._deviceAddedId); this.service.disconnect(this._deviceRemovedId); for (const device of this.service.devices) this._onDeviceRemoved(this.service, device, false); if (!this.settings.get_boolean('keep-alive-when-locked')) this.service.stop(); this.service.destroy(); } // Disconnect any keybindings this._keybindings.destroy(); // Disconnect from any GSettings changes this.settings.disconnect(this._enabledId); this.settings.disconnect(this._panelModeId); this.settings.run_dispose(); // Destroy the PanelMenu.SystemIndicator actors this.menu.destroy(); super.destroy(); } }); const ServiceIndicator = GObject.registerClass( class ServiceIndicator extends QuickSettings.SystemIndicator { _init() { super._init(); // Create the icon for the indicator this._indicator = this._addIndicator(); this._indicator.icon_name = 'org.gnome.Shell.Extensions.GSConnect-symbolic'; // Hide the indicator by default this._indicator.visible = false; // Create the toggle menu and associate it with the indicator this.quickSettingsItems.push(new ServiceToggle()); // Add the indicator to the panel and the toggle to the menu QuickSettingsMenu.addExternalIndicator(this); } destroy() { // Set enabled state to false to kill the service on destroy this.quickSettingsItems.forEach(item => item.destroy()); // Destroy the indicator this._indicator.destroy(); super.destroy(); } }); let serviceIndicator = null; export default class GSConnectExtension extends Extension { lockscreenInput = null; constructor(metadata) { super(metadata); setup(this.path); // If installed as a user extension, this checks the permissions // on certain critical files in the extension directory // to ensure that they have the executable bit set, // and makes them executable if not. Some packaging methods // (particularly GitHub Actions artifacts) automatically remove // executable bits from all contents, presumably for security. Utils.ensurePermissions(); // If installed as a user extension, this will install the Desktop entry, // DBus and systemd service files necessary for DBus activation and // GNotifications. Since there's no uninit()/uninstall() hook for extensions // and they're only used *by* GSConnect, they should be okay to leave. Utils.installService(); // These modify the notification source for GSConnect's GNotifications and // need to be active even when the extension is disabled (eg. lock screen). // Since they *only* affect notifications from GSConnect, it should be okay // to leave them applied. Notification.patchGSConnectNotificationSource(); Notification.patchGtkNotificationDaemon(); // This watches for the service to start and exports a custom clipboard // portal for use on Wayland Clipboard.watchService(); } enable() { serviceIndicator = new ServiceIndicator(); Notification.patchGtkNotificationSources(); this.lockscreenInput = new Input.LockscreenRemoteAccess(); this.lockscreenInput.patchInhibitor(); } disable() { serviceIndicator.destroy(); serviceIndicator = null; Notification.unpatchGtkNotificationSources(); if (this.lockscreenInput) { this.lockscreenInput.unpatchInhibitor(); this.lockscreenInput = null; } } }