extension.js 13 KB

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