extension.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Gio from 'gi://Gio';
  5. import GObject from 'gi://GObject';
  6. import * as Main from 'resource:///org/gnome/shell/ui/main.js';
  7. import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
  8. import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js';
  9. // Bootstrap
  10. import {
  11. Extension,
  12. gettext as _,
  13. ngettext
  14. } from 'resource:///org/gnome/shell/extensions/extension.js';
  15. import Config from './config.mjs';
  16. import * as Clipboard from './shell/clipboard.js';
  17. import * as Device from './shell/device.js';
  18. import * as Keybindings from './shell/keybindings.js';
  19. import * as Notification from './shell/notification.js';
  20. import * as Input from './shell/input.js';
  21. import * as Utils from './shell/utils.js';
  22. import Remote from './utils/remote.mjs';
  23. import setup from './utils/setup.mjs';
  24. const QuickSettingsMenu = Main.panel.statusArea.quickSettings;
  25. /**
  26. * A System Indicator used as the hub for spawning device indicators and
  27. * indicating that the extension is active when there are none.
  28. */
  29. const ServiceToggle = GObject.registerClass({
  30. GTypeName: 'GSConnectServiceIndicator',
  31. }, class ServiceToggle extends QuickSettings.QuickMenuToggle {
  32. _init() {
  33. super._init({
  34. title: 'GSConnect',
  35. toggleMode: true,
  36. });
  37. this.set({iconName: 'org.gnome.Shell.Extensions.GSConnect-symbolic'});
  38. // Set QuickMenuToggle header.
  39. this.menu.setHeader('org.gnome.Shell.Extensions.GSConnect-symbolic', 'GSConnect',
  40. _('Sync between your devices'));
  41. this._menus = {};
  42. this._keybindings = new Keybindings.Manager();
  43. // GSettings
  44. this.settings = new Gio.Settings({
  45. settings_schema: Config.GSCHEMA.lookup(
  46. 'org.gnome.Shell.Extensions.GSConnect',
  47. null
  48. ),
  49. path: '/org/gnome/shell/extensions/gsconnect/',
  50. });
  51. // Bind the toggle to enabled key
  52. this.settings.bind('enabled',
  53. this, 'checked',
  54. Gio.SettingsBindFlags.DEFAULT);
  55. this._enabledId = this.settings.connect(
  56. 'changed::enabled',
  57. this._onEnabledChanged.bind(this)
  58. );
  59. this._panelModeId = this.settings.connect(
  60. 'changed::show-indicators',
  61. this._sync.bind(this)
  62. );
  63. // Service Proxy
  64. this.service = new Remote.Service();
  65. this._deviceAddedId = this.service.connect(
  66. 'device-added',
  67. this._onDeviceAdded.bind(this)
  68. );
  69. this._deviceRemovedId = this.service.connect(
  70. 'device-removed',
  71. this._onDeviceRemoved.bind(this)
  72. );
  73. this._serviceChangedId = this.service.connect(
  74. 'notify::active',
  75. this._onServiceChanged.bind(this)
  76. );
  77. // Service Menu -> Devices Section
  78. this.deviceSection = new PopupMenu.PopupMenuSection();
  79. this.deviceSection.actor.add_style_class_name('gsconnect-device-section');
  80. this.settings.bind(
  81. 'show-indicators',
  82. this.deviceSection.actor,
  83. 'visible',
  84. Gio.SettingsBindFlags.INVERT_BOOLEAN
  85. );
  86. this.menu.addMenuItem(this.deviceSection);
  87. // Service Menu -> Separator
  88. this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
  89. // Service Menu -> "Mobile Settings"
  90. this.menu.addSettingsAction(
  91. _('Mobile Settings'),
  92. 'org.gnome.Shell.Extensions.GSConnect.Preferences.desktop');
  93. // Prime the service
  94. this._initService();
  95. }
  96. async _initService() {
  97. try {
  98. if (this.settings.get_boolean('enabled'))
  99. await this.service.start();
  100. else
  101. await this.service.reload();
  102. } catch (e) {
  103. logError(e, 'GSConnect');
  104. }
  105. }
  106. _sync() {
  107. const available = this.service.devices.filter(device => {
  108. return (device.connected && device.paired);
  109. });
  110. const panelMode = this.settings.get_boolean('show-indicators');
  111. // Hide status indicator if in Panel mode or no devices are available
  112. serviceIndicator._indicator.visible = (!panelMode && available.length);
  113. // Show device indicators in Panel mode if available
  114. for (const device of this.service.devices) {
  115. const isAvailable = available.includes(device);
  116. const indicator = Main.panel.statusArea[device.g_object_path];
  117. indicator.visible = panelMode && isAvailable;
  118. const menu = this._menus[device.g_object_path];
  119. menu.actor.visible = !panelMode && isAvailable;
  120. menu._title.actor.visible = !panelMode && isAvailable;
  121. }
  122. // Set subtitle on Quick Settings tile
  123. if (available.length === 1) {
  124. this.subtitle = available[0].name;
  125. } else if (available.length > 1) {
  126. // TRANSLATORS: %d is the number of devices connected
  127. this.subtitle = ngettext(
  128. '%d Connected',
  129. '%d Connected',
  130. available.length
  131. ).format(available.length);
  132. } else {
  133. this.subtitle = null;
  134. }
  135. }
  136. _onDeviceChanged(device, changed, invalidated) {
  137. try {
  138. const properties = changed.deepUnpack();
  139. if (properties.hasOwnProperty('Connected') ||
  140. properties.hasOwnProperty('Paired'))
  141. this._sync();
  142. } catch (e) {
  143. logError(e, 'GSConnect');
  144. }
  145. }
  146. _onDeviceAdded(service, device) {
  147. try {
  148. // Device Indicator
  149. const indicator = new Device.Indicator({device: device});
  150. Main.panel.addToStatusArea(device.g_object_path, indicator);
  151. // Device Menu
  152. const menu = new Device.Menu({
  153. device: device,
  154. menu_type: 'list',
  155. });
  156. this._menus[device.g_object_path] = menu;
  157. this.deviceSection.addMenuItem(menu);
  158. // Device Settings
  159. device.settings = new Gio.Settings({
  160. settings_schema: Config.GSCHEMA.lookup(
  161. 'org.gnome.Shell.Extensions.GSConnect.Device',
  162. true
  163. ),
  164. path: `/org/gnome/shell/extensions/gsconnect/device/${device.id}/`,
  165. });
  166. // Keyboard Shortcuts
  167. device.__keybindingsChangedId = device.settings.connect(
  168. 'changed::keybindings',
  169. this._onDeviceKeybindingsChanged.bind(this, device)
  170. );
  171. this._onDeviceKeybindingsChanged(device);
  172. // Watch the for status changes
  173. device.__deviceChangedId = device.connect(
  174. 'g-properties-changed',
  175. this._onDeviceChanged.bind(this)
  176. );
  177. this._sync();
  178. } catch (e) {
  179. logError(e, 'GSConnect');
  180. }
  181. }
  182. _onDeviceRemoved(service, device, sync = true) {
  183. try {
  184. // Stop watching for status changes
  185. if (device.__deviceChangedId)
  186. device.disconnect(device.__deviceChangedId);
  187. // Release keybindings
  188. if (device.__keybindingsChangedId) {
  189. device.settings.disconnect(device.__keybindingsChangedId);
  190. device._keybindings.map(id => this._keybindings.remove(id));
  191. }
  192. // Destroy the indicator
  193. Main.panel.statusArea[device.g_object_path].destroy();
  194. // Destroy the menu
  195. this._menus[device.g_object_path].destroy();
  196. delete this._menus[device.g_object_path];
  197. if (sync)
  198. this._sync();
  199. } catch (e) {
  200. logError(e, 'GSConnect');
  201. }
  202. }
  203. _onDeviceKeybindingsChanged(device) {
  204. try {
  205. // Reset any existing keybindings
  206. if (device.hasOwnProperty('_keybindings'))
  207. device._keybindings.map(id => this._keybindings.remove(id));
  208. device._keybindings = [];
  209. // Get the keybindings
  210. const keybindings = device.settings.get_value('keybindings').deepUnpack();
  211. // Apply the keybindings
  212. for (const [action, accelerator] of Object.entries(keybindings)) {
  213. const [, name, parameter] = Gio.Action.parse_detailed_name(action);
  214. const actionId = this._keybindings.add(
  215. accelerator,
  216. () => device.action_group.activate_action(name, parameter)
  217. );
  218. if (actionId !== 0)
  219. device._keybindings.push(actionId);
  220. }
  221. } catch (e) {
  222. logError(e, 'GSConnect');
  223. }
  224. }
  225. async _onEnabledChanged(settings, key) {
  226. try {
  227. if (this.settings.get_boolean('enabled'))
  228. await this.service.start();
  229. else
  230. await this.service.stop();
  231. } catch (e) {
  232. logError(e, 'GSConnect');
  233. }
  234. }
  235. async _onServiceChanged(service, pspec) {
  236. try {
  237. // If it's enabled, we should try to restart now
  238. if (this.settings.get_boolean('enabled'))
  239. await this.service.start();
  240. } catch (e) {
  241. logError(e, 'GSConnect');
  242. }
  243. }
  244. destroy() {
  245. // Unhook from Remote.Service
  246. if (this.service) {
  247. this.service.disconnect(this._serviceChangedId);
  248. this.service.disconnect(this._deviceAddedId);
  249. this.service.disconnect(this._deviceRemovedId);
  250. for (const device of this.service.devices)
  251. this._onDeviceRemoved(this.service, device, false);
  252. if (!this.settings.get_boolean('keep-alive-when-locked'))
  253. this.service.stop();
  254. this.service.destroy();
  255. }
  256. // Disconnect any keybindings
  257. this._keybindings.destroy();
  258. // Disconnect from any GSettings changes
  259. this.settings.disconnect(this._enabledId);
  260. this.settings.disconnect(this._panelModeId);
  261. this.settings.run_dispose();
  262. // Destroy the PanelMenu.SystemIndicator actors
  263. this.menu.destroy();
  264. super.destroy();
  265. }
  266. });
  267. const ServiceIndicator = GObject.registerClass(
  268. class ServiceIndicator extends QuickSettings.SystemIndicator {
  269. _init() {
  270. super._init();
  271. // Create the icon for the indicator
  272. this._indicator = this._addIndicator();
  273. this._indicator.icon_name = 'org.gnome.Shell.Extensions.GSConnect-symbolic';
  274. // Hide the indicator by default
  275. this._indicator.visible = false;
  276. // Create the toggle menu and associate it with the indicator
  277. this.quickSettingsItems.push(new ServiceToggle());
  278. // Add the indicator to the panel and the toggle to the menu
  279. QuickSettingsMenu.addExternalIndicator(this);
  280. }
  281. destroy() {
  282. // Set enabled state to false to kill the service on destroy
  283. this.quickSettingsItems.forEach(item => item.destroy());
  284. // Destroy the indicator
  285. this._indicator.destroy();
  286. super.destroy();
  287. }
  288. });
  289. let serviceIndicator = null;
  290. export default class GSConnectExtension extends Extension {
  291. lockscreenInput = null;
  292. constructor(metadata) {
  293. super(metadata);
  294. setup(this.path);
  295. // If installed as a user extension, this checks the permissions
  296. // on certain critical files in the extension directory
  297. // to ensure that they have the executable bit set,
  298. // and makes them executable if not. Some packaging methods
  299. // (particularly GitHub Actions artifacts) automatically remove
  300. // executable bits from all contents, presumably for security.
  301. Utils.ensurePermissions();
  302. // If installed as a user extension, this will install the Desktop entry,
  303. // DBus and systemd service files necessary for DBus activation and
  304. // GNotifications. Since there's no uninit()/uninstall() hook for extensions
  305. // and they're only used *by* GSConnect, they should be okay to leave.
  306. Utils.installService();
  307. // These modify the notification source for GSConnect's GNotifications and
  308. // need to be active even when the extension is disabled (eg. lock screen).
  309. // Since they *only* affect notifications from GSConnect, it should be okay
  310. // to leave them applied.
  311. Notification.patchGSConnectNotificationSource();
  312. Notification.patchGtkNotificationDaemon();
  313. // This watches for the service to start and exports a custom clipboard
  314. // portal for use on Wayland
  315. Clipboard.watchService();
  316. }
  317. enable() {
  318. serviceIndicator = new ServiceIndicator();
  319. Notification.patchGtkNotificationSources();
  320. this.lockscreenInput = new Input.LockscreenRemoteAccess();
  321. this.lockscreenInput.patchInhibitor();
  322. }
  323. disable() {
  324. serviceIndicator.destroy();
  325. serviceIndicator = null;
  326. Notification.unpatchGtkNotificationSources();
  327. if (this.lockscreenInput) {
  328. this.lockscreenInput.unpatchInhibitor();
  329. this.lockscreenInput = null;
  330. }
  331. }
  332. }