device.js 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gio = imports.gi.Gio;
  6. const GLib = imports.gi.GLib;
  7. const GObject = imports.gi.GObject;
  8. const Gtk = imports.gi.Gtk;
  9. const Pango = imports.gi.Pango;
  10. const Config = imports.config;
  11. const Keybindings = imports.preferences.keybindings;
  12. // Build a list of plugins and shortcuts for devices
  13. const DEVICE_PLUGINS = [];
  14. const DEVICE_SHORTCUTS = {};
  15. for (const name in imports.service.plugins) {
  16. const module = imports.service.plugins[name];
  17. if (module.Metadata === undefined)
  18. continue;
  19. // Plugins
  20. DEVICE_PLUGINS.push(name);
  21. // Shortcuts (GActions without parameters)
  22. for (const [name, action] of Object.entries(module.Metadata.actions)) {
  23. if (action.parameter_type === null)
  24. DEVICE_SHORTCUTS[name] = [action.icon_name, action.label];
  25. }
  26. }
  27. /**
  28. * A Gtk.ListBoxHeaderFunc for sections that adds separators between each row.
  29. *
  30. * @param {Gtk.ListBoxRow} row - The current row
  31. * @param {Gtk.ListBoxRow} before - The previous row
  32. */
  33. function rowSeparators(row, before) {
  34. const header = row.get_header();
  35. if (before === null) {
  36. if (header !== null)
  37. header.destroy();
  38. return;
  39. }
  40. if (header === null)
  41. row.set_header(new Gtk.Separator({visible: true}));
  42. }
  43. /**
  44. * A Gtk.ListBoxSortFunc for SectionRow rows
  45. *
  46. * @param {Gtk.ListBoxRow} row1 - The first row
  47. * @param {Gtk.ListBoxRow} row2 - The second row
  48. * @return {number} -1, 0 or 1
  49. */
  50. function titleSortFunc(row1, row2) {
  51. if (!row1.title || !row2.title)
  52. return 0;
  53. return row1.title.localeCompare(row2.title);
  54. }
  55. /**
  56. * A row for a section of settings
  57. */
  58. const SectionRow = GObject.registerClass({
  59. GTypeName: 'GSConnectPreferencesSectionRow',
  60. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-section-row.ui',
  61. Children: ['icon-image', 'title-label', 'subtitle-label'],
  62. Properties: {
  63. 'gicon': GObject.ParamSpec.object(
  64. 'gicon',
  65. 'GIcon',
  66. 'A GIcon for the row',
  67. GObject.ParamFlags.READWRITE,
  68. Gio.Icon.$gtype
  69. ),
  70. 'icon-name': GObject.ParamSpec.string(
  71. 'icon-name',
  72. 'Icon Name',
  73. 'An icon name for the row',
  74. GObject.ParamFlags.READWRITE,
  75. null
  76. ),
  77. 'subtitle': GObject.ParamSpec.string(
  78. 'subtitle',
  79. 'Subtitle',
  80. 'A subtitle for the row',
  81. GObject.ParamFlags.READWRITE,
  82. null
  83. ),
  84. 'title': GObject.ParamSpec.string(
  85. 'title',
  86. 'Title',
  87. 'A title for the row',
  88. GObject.ParamFlags.READWRITE,
  89. null
  90. ),
  91. 'widget': GObject.ParamSpec.object(
  92. 'widget',
  93. 'Widget',
  94. 'An action widget for the row',
  95. GObject.ParamFlags.READWRITE,
  96. Gtk.Widget.$gtype
  97. ),
  98. },
  99. }, class SectionRow extends Gtk.ListBoxRow {
  100. _init(params = {}) {
  101. super._init();
  102. // NOTE: we can't pass construct properties to _init() because the
  103. // template children are not assigned until after it runs.
  104. this.freeze_notify();
  105. Object.assign(this, params);
  106. this.thaw_notify();
  107. }
  108. get icon_name() {
  109. return this.icon_image.icon_name;
  110. }
  111. set icon_name(icon_name) {
  112. if (this.icon_name === icon_name)
  113. return;
  114. this.icon_image.visible = !!icon_name;
  115. this.icon_image.icon_name = icon_name;
  116. this.notify('icon-name');
  117. }
  118. get gicon() {
  119. return this.icon_image.gicon;
  120. }
  121. set gicon(gicon) {
  122. if (this.gicon === gicon)
  123. return;
  124. this.icon_image.visible = !!gicon;
  125. this.icon_image.gicon = gicon;
  126. this.notify('gicon');
  127. }
  128. get title() {
  129. return this.title_label.label;
  130. }
  131. set title(text) {
  132. if (this.title === text)
  133. return;
  134. this.title_label.visible = !!text;
  135. this.title_label.label = text;
  136. this.notify('title');
  137. }
  138. get subtitle() {
  139. return this.subtitle_label.label;
  140. }
  141. set subtitle(text) {
  142. if (this.subtitle === text)
  143. return;
  144. this.subtitle_label.visible = !!text;
  145. this.subtitle_label.label = text;
  146. this.notify('subtitle');
  147. }
  148. get widget() {
  149. if (this._widget === undefined)
  150. this._widget = null;
  151. return this._widget;
  152. }
  153. set widget(widget) {
  154. if (this.widget === widget)
  155. return;
  156. if (this.widget instanceof Gtk.Widget)
  157. this.widget.destroy();
  158. // Add the widget
  159. this._widget = widget;
  160. this.get_child().attach(widget, 2, 0, 1, 2);
  161. this.notify('widget');
  162. }
  163. });
  164. /**
  165. * Command Editor Dialog
  166. */
  167. const CommandEditor = GObject.registerClass({
  168. GTypeName: 'GSConnectPreferencesCommandEditor',
  169. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-command-editor.ui',
  170. Children: [
  171. 'cancel-button', 'save-button',
  172. 'command-entry', 'name-entry', 'command-chooser',
  173. ],
  174. }, class CommandEditor extends Gtk.Dialog {
  175. _onBrowseCommand(entry, icon_pos, event) {
  176. this.command_chooser.present();
  177. }
  178. _onCommandChosen(dialog, response_id) {
  179. if (response_id === Gtk.ResponseType.OK)
  180. this.command_entry.text = dialog.get_filename();
  181. dialog.hide();
  182. }
  183. _onEntryChanged(entry, pspec) {
  184. this.save_button.sensitive = (this.command_name && this.command_line);
  185. }
  186. get command_line() {
  187. return this.command_entry.text;
  188. }
  189. set command_line(text) {
  190. this.command_entry.text = text;
  191. }
  192. get command_name() {
  193. return this.name_entry.text;
  194. }
  195. set command_name(text) {
  196. this.name_entry.text = text;
  197. }
  198. });
  199. /**
  200. * A widget for configuring a remote device.
  201. */
  202. var Panel = GObject.registerClass({
  203. GTypeName: 'GSConnectPreferencesDevicePanel',
  204. Properties: {
  205. 'device': GObject.ParamSpec.object(
  206. 'device',
  207. 'Device',
  208. 'The device being configured',
  209. GObject.ParamFlags.READWRITE,
  210. GObject.Object.$gtype
  211. ),
  212. },
  213. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-device-panel.ui',
  214. Children: [
  215. 'sidebar', 'stack', 'infobar',
  216. // Sharing
  217. 'sharing', 'sharing-page',
  218. 'desktop-list', 'clipboard', 'clipboard-sync', 'mousepad', 'mpris', 'systemvolume',
  219. 'share', 'share-list', 'receive-files', 'receive-directory',
  220. // Battery
  221. 'battery',
  222. 'battery-device-label', 'battery-device', 'battery-device-list',
  223. 'battery-system-label', 'battery-system', 'battery-system-list',
  224. 'battery-custom-notification-value',
  225. // RunCommand
  226. 'runcommand', 'runcommand-page',
  227. 'command-list', 'command-add',
  228. // Notifications
  229. 'notification', 'notification-page',
  230. 'notification-list', 'notification-apps',
  231. // Telephony
  232. 'telephony', 'telephony-page',
  233. 'ringing-list', 'ringing-volume', 'talking-list', 'talking-volume',
  234. // Shortcuts
  235. 'shortcuts-page',
  236. 'shortcuts-actions', 'shortcuts-actions-title', 'shortcuts-actions-list',
  237. // Advanced
  238. 'advanced-page',
  239. 'plugin-list', 'experimental-list',
  240. 'device-menu',
  241. ],
  242. }, class Panel extends Gtk.Grid {
  243. _init(device) {
  244. super._init({
  245. device: device,
  246. });
  247. // GSettings
  248. this.settings = new Gio.Settings({
  249. settings_schema: Config.GSCHEMA.lookup(
  250. 'org.gnome.Shell.Extensions.GSConnect.Device',
  251. true
  252. ),
  253. path: `/org/gnome/shell/extensions/gsconnect/device/${device.id}/`,
  254. });
  255. // Infobar
  256. this.device.bind_property(
  257. 'paired',
  258. this.infobar,
  259. 'reveal-child',
  260. (GObject.BindingFlags.SYNC_CREATE |
  261. GObject.BindingFlags.INVERT_BOOLEAN)
  262. );
  263. this._setupActions();
  264. // Settings Pages
  265. this._sharingSettings();
  266. this._batterySettings();
  267. this._runcommandSettings();
  268. this._notificationSettings();
  269. this._telephonySettings();
  270. // --------------------------
  271. this._keybindingSettings();
  272. this._advancedSettings();
  273. // Separate plugins and other settings
  274. this.sidebar.set_header_func((row, before) => {
  275. if (row.get_name() === 'shortcuts')
  276. row.set_header(new Gtk.Separator({visible: true}));
  277. });
  278. }
  279. get menu() {
  280. if (this._menu === undefined) {
  281. this._menu = this.device_menu;
  282. this._menu.prepend_section(null, this.device.menu);
  283. this.insert_action_group('device', this.device.action_group);
  284. }
  285. return this._menu;
  286. }
  287. get_incoming_supported(type) {
  288. const incoming = this.settings.get_strv('incoming-capabilities');
  289. return incoming.includes(`kdeconnect.${type}`);
  290. }
  291. get_outgoing_supported(type) {
  292. const outgoing = this.settings.get_strv('outgoing-capabilities');
  293. return outgoing.includes(`kdeconnect.${type}`);
  294. }
  295. _onKeynavFailed(widget, direction) {
  296. if (direction === Gtk.DirectionType.UP && widget.prev)
  297. widget.prev.child_focus(direction);
  298. else if (direction === Gtk.DirectionType.DOWN && widget.next)
  299. widget.next.child_focus(direction);
  300. return true;
  301. }
  302. _onSwitcherRowSelected(box, row) {
  303. this.stack.set_visible_child_name(row.get_name());
  304. }
  305. _onSectionRowActivated(box, row) {
  306. if (row.widget !== undefined)
  307. row.widget.active = !row.widget.active;
  308. }
  309. _onToggleRowActivated(box, row) {
  310. const widget = row.get_child().get_child_at(1, 0);
  311. widget.active = !widget.active;
  312. }
  313. _onEncryptionInfo() {
  314. const dialog = new Gtk.MessageDialog({
  315. buttons: Gtk.ButtonsType.OK,
  316. text: _('Encryption Info'),
  317. secondary_text: this.device.encryption_info,
  318. modal: true,
  319. transient_for: this.get_toplevel(),
  320. });
  321. dialog.connect('response', (dialog) => dialog.destroy());
  322. dialog.present();
  323. }
  324. _deviceAction(action, parameter) {
  325. this.action_group.activate_action(action.name, parameter);
  326. }
  327. dispose() {
  328. if (this._commandEditor !== undefined)
  329. this._commandEditor.destroy();
  330. // Device signals
  331. this.device.action_group.disconnect(this._actionAddedId);
  332. this.device.action_group.disconnect(this._actionRemovedId);
  333. // GSettings
  334. for (const settings of Object.values(this._pluginSettings))
  335. settings.run_dispose();
  336. this.settings.disconnect(this._keybindingsId);
  337. this.settings.disconnect(this._disabledPluginsId);
  338. this.settings.disconnect(this._supportedPluginsId);
  339. this.settings.run_dispose();
  340. }
  341. pluginSettings(name) {
  342. if (this._pluginSettings === undefined)
  343. this._pluginSettings = {};
  344. if (!this._pluginSettings.hasOwnProperty(name)) {
  345. const meta = imports.service.plugins[name].Metadata;
  346. this._pluginSettings[name] = new Gio.Settings({
  347. settings_schema: Config.GSCHEMA.lookup(meta.id, -1),
  348. path: `${this.settings.path}plugin/${name}/`,
  349. });
  350. }
  351. return this._pluginSettings[name];
  352. }
  353. _setupActions() {
  354. this.actions = new Gio.SimpleActionGroup();
  355. this.insert_action_group('settings', this.actions);
  356. let settings = this.pluginSettings('battery');
  357. this.actions.add_action(settings.create_action('send-statistics'));
  358. this.actions.add_action(settings.create_action('low-battery-notification'));
  359. this.actions.add_action(settings.create_action('custom-battery-notification'));
  360. this.actions.add_action(settings.create_action('custom-battery-notification-value'));
  361. this.actions.add_action(settings.create_action('full-battery-notification'));
  362. settings = this.pluginSettings('clipboard');
  363. this.actions.add_action(settings.create_action('send-content'));
  364. this.actions.add_action(settings.create_action('receive-content'));
  365. settings = this.pluginSettings('contacts');
  366. this.actions.add_action(settings.create_action('contacts-source'));
  367. settings = this.pluginSettings('mousepad');
  368. this.actions.add_action(settings.create_action('share-control'));
  369. settings = this.pluginSettings('mpris');
  370. this.actions.add_action(settings.create_action('share-players'));
  371. settings = this.pluginSettings('notification');
  372. this.actions.add_action(settings.create_action('send-notifications'));
  373. this.actions.add_action(settings.create_action('send-active'));
  374. settings = this.pluginSettings('sftp');
  375. this.actions.add_action(settings.create_action('automount'));
  376. settings = this.pluginSettings('share');
  377. this.actions.add_action(settings.create_action('receive-files'));
  378. settings = this.pluginSettings('sms');
  379. this.actions.add_action(settings.create_action('legacy-sms'));
  380. settings = this.pluginSettings('systemvolume');
  381. this.actions.add_action(settings.create_action('share-sinks'));
  382. settings = this.pluginSettings('telephony');
  383. this.actions.add_action(settings.create_action('ringing-volume'));
  384. this.actions.add_action(settings.create_action('ringing-pause'));
  385. this.actions.add_action(settings.create_action('talking-volume'));
  386. this.actions.add_action(settings.create_action('talking-pause'));
  387. this.actions.add_action(settings.create_action('talking-microphone'));
  388. // Pair Actions
  389. const encryption_info = new Gio.SimpleAction({name: 'encryption-info'});
  390. encryption_info.connect('activate', this._onEncryptionInfo.bind(this));
  391. this.actions.add_action(encryption_info);
  392. const status_pair = new Gio.SimpleAction({name: 'pair'});
  393. status_pair.connect('activate', this._deviceAction.bind(this.device));
  394. this.settings.bind('paired', status_pair, 'enabled', 16);
  395. this.actions.add_action(status_pair);
  396. const status_unpair = new Gio.SimpleAction({name: 'unpair'});
  397. status_unpair.connect('activate', this._deviceAction.bind(this.device));
  398. this.settings.bind('paired', status_unpair, 'enabled', 0);
  399. this.actions.add_action(status_unpair);
  400. }
  401. /**
  402. * Sharing Settings
  403. */
  404. _sharingSettings() {
  405. // Share Plugin
  406. const settings = this.pluginSettings('share');
  407. settings.connect(
  408. 'changed::receive-directory',
  409. this._onReceiveDirectoryChanged.bind(this)
  410. );
  411. this._onReceiveDirectoryChanged(settings, 'receive-directory');
  412. // Visibility
  413. this.desktop_list.foreach(row => {
  414. const name = row.get_name();
  415. row.visible = this.get_outgoing_supported(`${name}.request`);
  416. });
  417. // Separators & Sorting
  418. this.desktop_list.set_header_func(rowSeparators);
  419. this.desktop_list.set_sort_func((row1, row2) => {
  420. row1 = row1.get_child().get_child_at(0, 0);
  421. row2 = row2.get_child().get_child_at(0, 0);
  422. return row1.label.localeCompare(row2.label);
  423. });
  424. this.share_list.set_header_func(rowSeparators);
  425. // Scroll with keyboard focus
  426. const sharing_box = this.sharing_page.get_child().get_child();
  427. sharing_box.set_focus_vadjustment(this.sharing_page.vadjustment);
  428. // Continue focus chain between lists
  429. this.desktop_list.next = this.share_list;
  430. this.share_list.prev = this.desktop_list;
  431. }
  432. _onReceiveDirectoryChanged(settings, key) {
  433. let receiveDir = settings.get_string(key);
  434. if (receiveDir.length === 0) {
  435. receiveDir = GLib.get_user_special_dir(
  436. GLib.UserDirectory.DIRECTORY_DOWNLOAD
  437. );
  438. // Account for some corner cases with a fallback
  439. const homeDir = GLib.get_home_dir();
  440. if (!receiveDir || receiveDir === homeDir)
  441. receiveDir = GLib.build_filenamev([homeDir, 'Downloads']);
  442. settings.set_string(key, receiveDir);
  443. }
  444. if (this.receive_directory.get_filename() !== receiveDir)
  445. this.receive_directory.set_filename(receiveDir);
  446. }
  447. _onReceiveDirectorySet(button) {
  448. const settings = this.pluginSettings('share');
  449. const receiveDir = settings.get_string('receive-directory');
  450. const filename = button.get_filename();
  451. if (filename !== receiveDir)
  452. settings.set_string('receive-directory', filename);
  453. }
  454. /**
  455. * Battery Settings
  456. */
  457. async _batterySettings() {
  458. try {
  459. this.battery_device_list.set_header_func(rowSeparators);
  460. this.battery_system_list.set_header_func(rowSeparators);
  461. const settings = this.pluginSettings('battery');
  462. const oldLevel = settings.get_uint('custom-battery-notification-value');
  463. this.battery_custom_notification_value.set_value(oldLevel);
  464. // If the device can't handle statistics we're done
  465. if (!this.get_incoming_supported('battery')) {
  466. this.battery_system_label.visible = false;
  467. this.battery_system.visible = false;
  468. return;
  469. }
  470. // Check UPower for a battery
  471. const hasBattery = await new Promise((resolve, reject) => {
  472. Gio.DBus.system.call(
  473. 'org.freedesktop.UPower',
  474. '/org/freedesktop/UPower/devices/DisplayDevice',
  475. 'org.freedesktop.DBus.Properties',
  476. 'Get',
  477. new GLib.Variant('(ss)', [
  478. 'org.freedesktop.UPower.Device',
  479. 'IsPresent',
  480. ]),
  481. null,
  482. Gio.DBusCallFlags.NONE,
  483. -1,
  484. null,
  485. (connection, res) => {
  486. try {
  487. const variant = connection.call_finish(res);
  488. const value = variant.deepUnpack()[0];
  489. const isPresent = value.get_boolean();
  490. resolve(isPresent);
  491. } catch (e) {
  492. resolve(false);
  493. }
  494. }
  495. );
  496. });
  497. this.battery_system_label.visible = hasBattery;
  498. this.battery_system.visible = hasBattery;
  499. } catch (e) {
  500. this.battery_system_label.visible = false;
  501. this.battery_system.visible = false;
  502. }
  503. }
  504. _setCustomChargeLevel(spin) {
  505. const settings = this.pluginSettings('battery');
  506. settings.set_uint('custom-battery-notification-value', spin.get_value_as_int());
  507. }
  508. /**
  509. * RunCommand Page
  510. */
  511. _runcommandSettings() {
  512. // Scroll with keyboard focus
  513. const runcommand_box = this.runcommand_page.get_child().get_child();
  514. runcommand_box.set_focus_vadjustment(this.runcommand_page.vadjustment);
  515. // Local Command List
  516. const settings = this.pluginSettings('runcommand');
  517. this._commands = settings.get_value('command-list').recursiveUnpack();
  518. this.command_list.set_sort_func(this._sortCommands);
  519. this.command_list.set_header_func(rowSeparators);
  520. for (const uuid of Object.keys(this._commands))
  521. this._insertCommand(uuid);
  522. }
  523. _sortCommands(row1, row2) {
  524. if (!row1.title || !row2.title)
  525. return 1;
  526. return row1.title.localeCompare(row2.title);
  527. }
  528. _insertCommand(uuid) {
  529. const row = new SectionRow({
  530. title: this._commands[uuid].name,
  531. subtitle: this._commands[uuid].command,
  532. activatable: false,
  533. });
  534. row.set_name(uuid);
  535. row.subtitle_label.ellipsize = Pango.EllipsizeMode.MIDDLE;
  536. const editButton = new Gtk.Button({
  537. image: new Gtk.Image({
  538. icon_name: 'document-edit-symbolic',
  539. pixel_size: 16,
  540. visible: true,
  541. }),
  542. tooltip_text: _('Edit'),
  543. valign: Gtk.Align.CENTER,
  544. vexpand: true,
  545. visible: true,
  546. });
  547. editButton.connect('clicked', this._onEditCommand.bind(this));
  548. editButton.get_accessible().set_name(_('Edit'));
  549. row.get_child().attach(editButton, 2, 0, 1, 2);
  550. const deleteButton = new Gtk.Button({
  551. image: new Gtk.Image({
  552. icon_name: 'edit-delete-symbolic',
  553. pixel_size: 16,
  554. visible: true,
  555. }),
  556. tooltip_text: _('Remove'),
  557. valign: Gtk.Align.CENTER,
  558. vexpand: true,
  559. visible: true,
  560. });
  561. deleteButton.connect('clicked', this._onDeleteCommand.bind(this));
  562. deleteButton.get_accessible().set_name(_('Remove'));
  563. row.get_child().attach(deleteButton, 3, 0, 1, 2);
  564. this.command_list.add(row);
  565. }
  566. _onEditCommand(widget) {
  567. if (this._commandEditor === undefined) {
  568. this._commandEditor = new CommandEditor({
  569. modal: true,
  570. transient_for: this.get_toplevel(),
  571. use_header_bar: true,
  572. });
  573. this._commandEditor.connect(
  574. 'response',
  575. this._onSaveCommand.bind(this)
  576. );
  577. this._commandEditor.resize(1, 1);
  578. }
  579. if (widget instanceof Gtk.Button) {
  580. const row = widget.get_ancestor(Gtk.ListBoxRow.$gtype);
  581. const uuid = row.get_name();
  582. this._commandEditor.uuid = uuid;
  583. this._commandEditor.command_name = this._commands[uuid].name;
  584. this._commandEditor.command_line = this._commands[uuid].command;
  585. } else {
  586. this._commandEditor.uuid = GLib.uuid_string_random();
  587. this._commandEditor.command_name = '';
  588. this._commandEditor.command_line = '';
  589. }
  590. this._commandEditor.present();
  591. }
  592. _storeCommands() {
  593. const variant = {};
  594. for (const [uuid, command] of Object.entries(this._commands))
  595. variant[uuid] = new GLib.Variant('a{ss}', command);
  596. this.pluginSettings('runcommand').set_value(
  597. 'command-list',
  598. new GLib.Variant('a{sv}', variant)
  599. );
  600. }
  601. _onDeleteCommand(button) {
  602. const row = button.get_ancestor(Gtk.ListBoxRow.$gtype);
  603. delete this._commands[row.get_name()];
  604. row.destroy();
  605. this._storeCommands();
  606. }
  607. _onSaveCommand(dialog, response_id) {
  608. if (response_id === Gtk.ResponseType.ACCEPT) {
  609. this._commands[dialog.uuid] = {
  610. name: dialog.command_name,
  611. command: dialog.command_line,
  612. };
  613. this._storeCommands();
  614. //
  615. let row = null;
  616. for (const child of this.command_list.get_children()) {
  617. if (child.get_name() === dialog.uuid) {
  618. row = child;
  619. break;
  620. }
  621. }
  622. if (row === null) {
  623. this._insertCommand(dialog.uuid);
  624. } else {
  625. row.set_name(dialog.uuid);
  626. row.title = dialog.command_name;
  627. row.subtitle = dialog.command_line;
  628. }
  629. }
  630. dialog.hide();
  631. }
  632. /**
  633. * Notification Settings
  634. */
  635. _notificationSettings() {
  636. const settings = this.pluginSettings('notification');
  637. settings.bind(
  638. 'send-notifications',
  639. this.notification_apps,
  640. 'sensitive',
  641. Gio.SettingsBindFlags.DEFAULT
  642. );
  643. // Separators & Sorting
  644. this.notification_list.set_header_func(rowSeparators);
  645. // Scroll with keyboard focus
  646. const notification_box = this.notification_page.get_child().get_child();
  647. notification_box.set_focus_vadjustment(this.notification_page.vadjustment);
  648. // Continue focus chain between lists
  649. this.notification_list.next = this.notification_apps;
  650. this.notification_apps.prev = this.notification_list;
  651. this.notification_apps.set_sort_func(titleSortFunc);
  652. this.notification_apps.set_header_func(rowSeparators);
  653. this._populateApplications(settings);
  654. }
  655. _toggleNotification(widget) {
  656. try {
  657. const row = widget.get_ancestor(Gtk.ListBoxRow.$gtype);
  658. const settings = this.pluginSettings('notification');
  659. let applications = {};
  660. try {
  661. applications = JSON.parse(settings.get_string('applications'));
  662. } catch (e) {
  663. applications = {};
  664. }
  665. applications[row.title].enabled = !applications[row.title].enabled;
  666. row.widget.state = applications[row.title].enabled;
  667. settings.set_string('applications', JSON.stringify(applications));
  668. } catch (e) {
  669. logError(e);
  670. }
  671. }
  672. _populateApplications(settings) {
  673. const applications = this._queryApplications(settings);
  674. for (const name in applications) {
  675. const row = new SectionRow({
  676. gicon: Gio.Icon.new_for_string(applications[name].iconName),
  677. title: name,
  678. height_request: 48,
  679. widget: new Gtk.Switch({
  680. state: applications[name].enabled,
  681. margin_start: 12,
  682. margin_end: 12,
  683. halign: Gtk.Align.END,
  684. valign: Gtk.Align.CENTER,
  685. vexpand: true,
  686. visible: true,
  687. }),
  688. });
  689. row.widget.connect('notify::active', this._toggleNotification.bind(this));
  690. this.notification_apps.add(row);
  691. }
  692. }
  693. _queryApplications(settings) {
  694. let applications = {};
  695. try {
  696. applications = JSON.parse(settings.get_string('applications'));
  697. } catch (e) {
  698. applications = {};
  699. }
  700. // Scan applications that statically declare to show notifications
  701. const ignoreId = 'org.gnome.Shell.Extensions.GSConnect.desktop';
  702. for (const appInfo of Gio.AppInfo.get_all()) {
  703. if (appInfo.get_id() === ignoreId)
  704. continue;
  705. if (!appInfo.get_boolean('X-GNOME-UsesNotifications'))
  706. continue;
  707. const appName = appInfo.get_name();
  708. if (appName === null || applications.hasOwnProperty(appName))
  709. continue;
  710. let icon = appInfo.get_icon();
  711. icon = (icon) ? icon.to_string() : 'application-x-executable';
  712. applications[appName] = {
  713. iconName: icon,
  714. enabled: true,
  715. };
  716. }
  717. settings.set_string('applications', JSON.stringify(applications));
  718. return applications;
  719. }
  720. /**
  721. * Telephony Settings
  722. */
  723. _telephonySettings() {
  724. // Continue focus chain between lists
  725. this.ringing_list.next = this.talking_list;
  726. this.talking_list.prev = this.ringing_list;
  727. this.ringing_list.set_header_func(rowSeparators);
  728. this.talking_list.set_header_func(rowSeparators);
  729. }
  730. /**
  731. * Keyboard Shortcuts
  732. */
  733. _keybindingSettings() {
  734. // Scroll with keyboard focus
  735. const shortcuts_box = this.shortcuts_page.get_child().get_child();
  736. shortcuts_box.set_focus_vadjustment(this.shortcuts_page.vadjustment);
  737. // Filter & Sort
  738. this.shortcuts_actions_list.set_filter_func(this._filterPluginKeybindings.bind(this));
  739. this.shortcuts_actions_list.set_header_func(rowSeparators);
  740. this.shortcuts_actions_list.set_sort_func(titleSortFunc);
  741. // Init
  742. for (const name in DEVICE_SHORTCUTS)
  743. this._addPluginKeybinding(name);
  744. this._setPluginKeybindings();
  745. // Watch for GAction and Keybinding changes
  746. this._actionAddedId = this.device.action_group.connect(
  747. 'action-added',
  748. () => this.shortcuts_actions_list.invalidate_filter()
  749. );
  750. this._actionRemovedId = this.device.action_group.connect(
  751. 'action-removed',
  752. () => this.shortcuts_actions_list.invalidate_filter()
  753. );
  754. this._keybindingsId = this.settings.connect(
  755. 'changed::keybindings',
  756. this._setPluginKeybindings.bind(this)
  757. );
  758. }
  759. _addPluginKeybinding(name) {
  760. const [icon_name, label] = DEVICE_SHORTCUTS[name];
  761. const widget = new Gtk.Label({
  762. label: _('Disabled'),
  763. visible: true,
  764. });
  765. widget.get_style_context().add_class('dim-label');
  766. const row = new SectionRow({
  767. height_request: 48,
  768. icon_name: icon_name,
  769. title: label,
  770. widget: widget,
  771. });
  772. row.icon_image.pixel_size = 16;
  773. row.action = name;
  774. this.shortcuts_actions_list.add(row);
  775. }
  776. _filterPluginKeybindings(row) {
  777. return this.device.action_group.has_action(row.action);
  778. }
  779. _setPluginKeybindings() {
  780. const keybindings = this.settings.get_value('keybindings').deepUnpack();
  781. this.shortcuts_actions_list.foreach(row => {
  782. if (keybindings[row.action]) {
  783. const accel = Gtk.accelerator_parse(keybindings[row.action]);
  784. row.widget.label = Gtk.accelerator_get_label(...accel);
  785. } else {
  786. row.widget.label = _('Disabled');
  787. }
  788. });
  789. }
  790. _onResetActionShortcuts(button) {
  791. const keybindings = this.settings.get_value('keybindings').deepUnpack();
  792. for (const action in keybindings) {
  793. // Don't reset remote command shortcuts
  794. if (!action.includes('::'))
  795. delete keybindings[action];
  796. }
  797. this.settings.set_value(
  798. 'keybindings',
  799. new GLib.Variant('a{ss}', keybindings)
  800. );
  801. }
  802. async _onShortcutRowActivated(box, row) {
  803. try {
  804. const keybindings = this.settings.get_value('keybindings').deepUnpack();
  805. let accel = keybindings[row.action] || null;
  806. accel = await Keybindings.getAccelerator(row.title, accel);
  807. if (accel)
  808. keybindings[row.action] = accel;
  809. else
  810. delete keybindings[row.action];
  811. this.settings.set_value(
  812. 'keybindings',
  813. new GLib.Variant('a{ss}', keybindings)
  814. );
  815. } catch (e) {
  816. logError(e);
  817. }
  818. }
  819. /**
  820. * Advanced Page
  821. */
  822. _advancedSettings() {
  823. // Scroll with keyboard focus
  824. const advanced_box = this.advanced_page.get_child().get_child();
  825. advanced_box.set_focus_vadjustment(this.advanced_page.vadjustment);
  826. // Sort & Separate
  827. this.plugin_list.set_header_func(rowSeparators);
  828. this.plugin_list.set_sort_func(titleSortFunc);
  829. this.experimental_list.set_header_func(rowSeparators);
  830. // Continue focus chain between lists
  831. this.plugin_list.next = this.experimental_list;
  832. this.experimental_list.prev = this.plugin_list;
  833. this._disabledPluginsId = this.settings.connect(
  834. 'changed::disabled-plugins',
  835. this._onPluginsChanged.bind(this)
  836. );
  837. this._supportedPluginsId = this.settings.connect(
  838. 'changed::supported-plugins',
  839. this._onPluginsChanged.bind(this)
  840. );
  841. this._onPluginsChanged(this.settings, null);
  842. for (const name of DEVICE_PLUGINS)
  843. this._addPlugin(name);
  844. }
  845. _onPluginsChanged(settings, key) {
  846. if (key === 'disabled-plugins' || this._disabledPlugins === undefined)
  847. this._disabledPlugins = settings.get_strv('disabled-plugins');
  848. if (key === 'supported-plugins' || this._supportedPlugins === undefined)
  849. this._supportedPlugins = settings.get_strv('supported-plugins');
  850. this._enabledPlugins = this._supportedPlugins.filter(name => {
  851. return !this._disabledPlugins.includes(name);
  852. });
  853. if (key !== null)
  854. this._updatePlugins();
  855. }
  856. _addPlugin(name) {
  857. const plugin = imports.service.plugins[name];
  858. const row = new SectionRow({
  859. height_request: 48,
  860. title: plugin.Metadata.label,
  861. subtitle: plugin.Metadata.description || '',
  862. visible: this._supportedPlugins.includes(name),
  863. widget: new Gtk.Switch({
  864. active: this._enabledPlugins.includes(name),
  865. valign: Gtk.Align.CENTER,
  866. vexpand: true,
  867. visible: true,
  868. }),
  869. });
  870. row.widget.connect('notify::active', this._togglePlugin.bind(this));
  871. row.set_name(name);
  872. if (this.hasOwnProperty(name))
  873. this[name].visible = row.widget.active;
  874. this.plugin_list.add(row);
  875. }
  876. _updatePlugins(settings, key) {
  877. for (const row of this.plugin_list.get_children()) {
  878. const name = row.get_name();
  879. row.visible = this._supportedPlugins.includes(name);
  880. row.widget.active = this._enabledPlugins.includes(name);
  881. if (this.hasOwnProperty(name))
  882. this[name].visible = row.widget.active;
  883. }
  884. }
  885. _togglePlugin(widget) {
  886. try {
  887. const name = widget.get_ancestor(Gtk.ListBoxRow.$gtype).get_name();
  888. const index = this._disabledPlugins.indexOf(name);
  889. // Either add or remove the plugin from the disabled list
  890. if (index > -1)
  891. this._disabledPlugins.splice(index, 1);
  892. else
  893. this._disabledPlugins.push(name);
  894. this.settings.set_strv('disabled-plugins', this._disabledPlugins);
  895. } catch (e) {
  896. logError(e);
  897. }
  898. }
  899. });