device.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113
  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 GLib from 'gi://GLib';
  6. import GObject from 'gi://GObject';
  7. import Gtk from 'gi://Gtk';
  8. import Pango from 'gi://Pango';
  9. import Config from '../config.js';
  10. import plugins from '../service/plugins/index.js';
  11. import * as Keybindings from './keybindings.js';
  12. // Build a list of plugins and shortcuts for devices
  13. const DEVICE_PLUGINS = [];
  14. const DEVICE_SHORTCUTS = {};
  15. for (const name in plugins) {
  16. const module = 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. export 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. * @returns {number} -1, 0 or 1
  49. */
  50. export 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. export const 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. 'links', 'links-list', 'launch-urls',
  221. // Battery
  222. 'battery',
  223. 'battery-device-label', 'battery-device', 'battery-device-list',
  224. 'battery-system-label', 'battery-system', 'battery-system-list',
  225. 'battery-custom-notification-value',
  226. // RunCommand
  227. 'runcommand', 'runcommand-page',
  228. 'command-list', 'command-add',
  229. // Notifications
  230. 'notification', 'notification-page',
  231. 'notification-list', 'notification-apps',
  232. // Telephony
  233. 'telephony', 'telephony-page',
  234. 'ringing-list', 'ringing-volume', 'talking-list', 'talking-volume',
  235. // Shortcuts
  236. 'shortcuts-page',
  237. 'shortcuts-actions', 'shortcuts-actions-title', 'shortcuts-actions-list',
  238. // Advanced
  239. 'advanced-page',
  240. 'plugin-list', 'experimental-list',
  241. 'device-menu',
  242. ],
  243. }, class Panel extends Gtk.Grid {
  244. _init(device) {
  245. super._init({
  246. device: device,
  247. });
  248. // GSettings
  249. this.settings = new Gio.Settings({
  250. settings_schema: Config.GSCHEMA.lookup(
  251. 'org.gnome.Shell.Extensions.GSConnect.Device',
  252. true
  253. ),
  254. path: `/org/gnome/shell/extensions/gsconnect/device/${device.id}/`,
  255. });
  256. // Infobar
  257. this.device.bind_property(
  258. 'paired',
  259. this.infobar,
  260. 'reveal-child',
  261. (GObject.BindingFlags.SYNC_CREATE |
  262. GObject.BindingFlags.INVERT_BOOLEAN)
  263. );
  264. this._setupActions();
  265. // Settings Pages
  266. this._sharingSettings();
  267. this._batterySettings();
  268. this._runcommandSettings();
  269. this._notificationSettings();
  270. this._telephonySettings();
  271. // --------------------------
  272. this._keybindingSettings();
  273. this._advancedSettings();
  274. // Separate plugins and other settings
  275. this.sidebar.set_header_func((row, before) => {
  276. if (row.get_name() === 'shortcuts')
  277. row.set_header(new Gtk.Separator({visible: true}));
  278. });
  279. }
  280. get menu() {
  281. if (this._menu === undefined) {
  282. this._menu = this.device_menu;
  283. this._menu.prepend_section(null, this.device.menu);
  284. this.insert_action_group('device', this.device.action_group);
  285. }
  286. return this._menu;
  287. }
  288. get_incoming_supported(type) {
  289. const incoming = this.settings.get_strv('incoming-capabilities');
  290. return incoming.includes(`kdeconnect.${type}`);
  291. }
  292. get_outgoing_supported(type) {
  293. const outgoing = this.settings.get_strv('outgoing-capabilities');
  294. return outgoing.includes(`kdeconnect.${type}`);
  295. }
  296. _onKeynavFailed(widget, direction) {
  297. if (direction === Gtk.DirectionType.UP && widget.prev)
  298. widget.prev.child_focus(direction);
  299. else if (direction === Gtk.DirectionType.DOWN && widget.next)
  300. widget.next.child_focus(direction);
  301. return true;
  302. }
  303. _onSwitcherRowSelected(box, row) {
  304. this.stack.set_visible_child_name(row.get_name());
  305. }
  306. _onSectionRowActivated(box, row) {
  307. if (row.widget !== undefined)
  308. row.widget.active = !row.widget.active;
  309. }
  310. _onToggleRowActivated(box, row) {
  311. const widget = row.get_child().get_child_at(1, 0);
  312. widget.active = !widget.active;
  313. }
  314. _onEncryptionInfo() {
  315. const dialog = new Gtk.MessageDialog({
  316. buttons: Gtk.ButtonsType.OK,
  317. text: _('Encryption Info'),
  318. secondary_text: this.device.encryption_info,
  319. modal: true,
  320. transient_for: this.get_toplevel(),
  321. });
  322. dialog.connect('response', (dialog) => dialog.destroy());
  323. dialog.present();
  324. }
  325. _deviceAction(action, parameter) {
  326. this.action_group.activate_action(action.name, parameter);
  327. }
  328. dispose() {
  329. if (this._commandEditor !== undefined)
  330. this._commandEditor.destroy();
  331. // Device signals
  332. this.device.action_group.disconnect(this._actionAddedId);
  333. this.device.action_group.disconnect(this._actionRemovedId);
  334. // GSettings
  335. for (const settings of Object.values(this._pluginSettings))
  336. settings.run_dispose();
  337. this.settings.disconnect(this._keybindingsId);
  338. this.settings.disconnect(this._disabledPluginsId);
  339. this.settings.disconnect(this._supportedPluginsId);
  340. this.settings.run_dispose();
  341. }
  342. pluginSettings(name) {
  343. if (this._pluginSettings === undefined)
  344. this._pluginSettings = {};
  345. if (!this._pluginSettings.hasOwnProperty(name)) {
  346. const meta = plugins[name].Metadata;
  347. this._pluginSettings[name] = new Gio.Settings({
  348. settings_schema: Config.GSCHEMA.lookup(meta.id, -1),
  349. path: `${this.settings.path}plugin/${name}/`,
  350. });
  351. }
  352. return this._pluginSettings[name];
  353. }
  354. _setupActions() {
  355. this.actions = new Gio.SimpleActionGroup();
  356. this.insert_action_group('settings', this.actions);
  357. let settings = this.pluginSettings('battery');
  358. this.actions.add_action(settings.create_action('send-statistics'));
  359. this.actions.add_action(settings.create_action('low-battery-notification'));
  360. this.actions.add_action(settings.create_action('custom-battery-notification'));
  361. this.actions.add_action(settings.create_action('custom-battery-notification-value'));
  362. this.actions.add_action(settings.create_action('full-battery-notification'));
  363. settings = this.pluginSettings('clipboard');
  364. this.actions.add_action(settings.create_action('send-content'));
  365. this.actions.add_action(settings.create_action('receive-content'));
  366. settings = this.pluginSettings('contacts');
  367. this.actions.add_action(settings.create_action('contacts-source'));
  368. settings = this.pluginSettings('mousepad');
  369. this.actions.add_action(settings.create_action('share-control'));
  370. settings = this.pluginSettings('mpris');
  371. this.actions.add_action(settings.create_action('share-players'));
  372. settings = this.pluginSettings('notification');
  373. this.actions.add_action(settings.create_action('send-notifications'));
  374. this.actions.add_action(settings.create_action('send-active'));
  375. settings = this.pluginSettings('sftp');
  376. this.actions.add_action(settings.create_action('automount'));
  377. settings = this.pluginSettings('share');
  378. this.actions.add_action(settings.create_action('receive-files'));
  379. this.actions.add_action(settings.create_action('launch-urls'));
  380. settings = this.pluginSettings('sms');
  381. this.actions.add_action(settings.create_action('legacy-sms'));
  382. settings = this.pluginSettings('systemvolume');
  383. this.actions.add_action(settings.create_action('share-sinks'));
  384. settings = this.pluginSettings('telephony');
  385. this.actions.add_action(settings.create_action('ringing-volume'));
  386. this.actions.add_action(settings.create_action('ringing-pause'));
  387. this.actions.add_action(settings.create_action('talking-volume'));
  388. this.actions.add_action(settings.create_action('talking-pause'));
  389. this.actions.add_action(settings.create_action('talking-microphone'));
  390. // Pair Actions
  391. const encryption_info = new Gio.SimpleAction({name: 'encryption-info'});
  392. encryption_info.connect('activate', this._onEncryptionInfo.bind(this));
  393. this.actions.add_action(encryption_info);
  394. const status_pair = new Gio.SimpleAction({name: 'pair'});
  395. status_pair.connect('activate', this._deviceAction.bind(this.device));
  396. this.settings.bind('paired', status_pair, 'enabled', 16);
  397. this.actions.add_action(status_pair);
  398. const status_unpair = new Gio.SimpleAction({name: 'unpair'});
  399. status_unpair.connect('activate', this._deviceAction.bind(this.device));
  400. this.settings.bind('paired', status_unpair, 'enabled', 0);
  401. this.actions.add_action(status_unpair);
  402. }
  403. /**
  404. * Sharing Settings
  405. */
  406. _sharingSettings() {
  407. // Share Plugin
  408. const settings = this.pluginSettings('share');
  409. settings.connect(
  410. 'changed::receive-directory',
  411. this._onReceiveDirectoryChanged.bind(this)
  412. );
  413. this._onReceiveDirectoryChanged(settings, 'receive-directory');
  414. // Visibility
  415. this.desktop_list.foreach(row => {
  416. const name = row.get_name();
  417. row.visible = this.get_outgoing_supported(`${name}.request`);
  418. });
  419. // Separators & Sorting
  420. this.desktop_list.set_header_func(rowSeparators);
  421. this.desktop_list.set_sort_func((row1, row2) => {
  422. row1 = row1.get_child().get_child_at(0, 0);
  423. row2 = row2.get_child().get_child_at(0, 0);
  424. return row1.label.localeCompare(row2.label);
  425. });
  426. this.share_list.set_header_func(rowSeparators);
  427. // Scroll with keyboard focus
  428. const sharing_box = this.sharing_page.get_child().get_child();
  429. sharing_box.set_focus_vadjustment(this.sharing_page.vadjustment);
  430. // Continue focus chain between lists
  431. this.desktop_list.next = this.share_list;
  432. this.share_list.prev = this.desktop_list;
  433. }
  434. _onReceiveDirectoryChanged(settings, key) {
  435. let receiveDir = settings.get_string(key);
  436. if (receiveDir.length === 0) {
  437. receiveDir = GLib.get_user_special_dir(
  438. GLib.UserDirectory.DIRECTORY_DOWNLOAD
  439. );
  440. // Account for some corner cases with a fallback
  441. const homeDir = GLib.get_home_dir();
  442. if (!receiveDir || receiveDir === homeDir)
  443. receiveDir = GLib.build_filenamev([homeDir, 'Downloads']);
  444. settings.set_string(key, receiveDir);
  445. }
  446. if (this.receive_directory.get_filename() !== receiveDir)
  447. this.receive_directory.set_filename(receiveDir);
  448. }
  449. _onReceiveDirectorySet(button) {
  450. const settings = this.pluginSettings('share');
  451. const receiveDir = settings.get_string('receive-directory');
  452. const filename = button.get_filename();
  453. if (filename !== receiveDir)
  454. settings.set_string('receive-directory', filename);
  455. }
  456. /**
  457. * Battery Settings
  458. */
  459. async _batterySettings() {
  460. try {
  461. this.battery_device_list.set_header_func(rowSeparators);
  462. this.battery_system_list.set_header_func(rowSeparators);
  463. const settings = this.pluginSettings('battery');
  464. const oldLevel = settings.get_uint('custom-battery-notification-value');
  465. this.battery_custom_notification_value.set_value(oldLevel);
  466. // If the device can't handle statistics we're done
  467. if (!this.get_incoming_supported('battery')) {
  468. this.battery_system_label.visible = false;
  469. this.battery_system.visible = false;
  470. return;
  471. }
  472. // Check UPower for a battery
  473. const hasBattery = await new Promise((resolve, reject) => {
  474. Gio.DBus.system.call(
  475. 'org.freedesktop.UPower',
  476. '/org/freedesktop/UPower/devices/DisplayDevice',
  477. 'org.freedesktop.DBus.Properties',
  478. 'Get',
  479. new GLib.Variant('(ss)', [
  480. 'org.freedesktop.UPower.Device',
  481. 'IsPresent',
  482. ]),
  483. null,
  484. Gio.DBusCallFlags.NONE,
  485. -1,
  486. null,
  487. (connection, res) => {
  488. try {
  489. const variant = connection.call_finish(res);
  490. const value = variant.deepUnpack()[0];
  491. const isPresent = value.get_boolean();
  492. resolve(isPresent);
  493. } catch {
  494. resolve(false);
  495. }
  496. }
  497. );
  498. });
  499. this.battery_system_label.visible = hasBattery;
  500. this.battery_system.visible = hasBattery;
  501. } catch {
  502. this.battery_system_label.visible = false;
  503. this.battery_system.visible = false;
  504. }
  505. }
  506. _setCustomChargeLevel(spin) {
  507. const settings = this.pluginSettings('battery');
  508. settings.set_uint('custom-battery-notification-value', spin.get_value_as_int());
  509. }
  510. /**
  511. * RunCommand Page
  512. */
  513. _runcommandSettings() {
  514. // Scroll with keyboard focus
  515. const runcommand_box = this.runcommand_page.get_child().get_child();
  516. runcommand_box.set_focus_vadjustment(this.runcommand_page.vadjustment);
  517. // Local Command List
  518. const settings = this.pluginSettings('runcommand');
  519. this._commands = settings.get_value('command-list').recursiveUnpack();
  520. this.command_list.set_sort_func(this._sortCommands);
  521. this.command_list.set_header_func(rowSeparators);
  522. for (const uuid of Object.keys(this._commands))
  523. this._insertCommand(uuid);
  524. }
  525. _sortCommands(row1, row2) {
  526. if (!row1.title || !row2.title)
  527. return 1;
  528. return row1.title.localeCompare(row2.title);
  529. }
  530. _insertCommand(uuid) {
  531. const row = new SectionRow({
  532. title: this._commands[uuid].name,
  533. subtitle: this._commands[uuid].command,
  534. activatable: false,
  535. });
  536. row.set_name(uuid);
  537. row.subtitle_label.ellipsize = Pango.EllipsizeMode.MIDDLE;
  538. const editButton = new Gtk.Button({
  539. image: new Gtk.Image({
  540. icon_name: 'document-edit-symbolic',
  541. pixel_size: 16,
  542. visible: true,
  543. }),
  544. tooltip_text: _('Edit'),
  545. valign: Gtk.Align.CENTER,
  546. vexpand: true,
  547. visible: true,
  548. });
  549. editButton.connect('clicked', this._onEditCommand.bind(this));
  550. editButton.get_accessible().set_name(_('Edit'));
  551. row.get_child().attach(editButton, 2, 0, 1, 2);
  552. const deleteButton = new Gtk.Button({
  553. image: new Gtk.Image({
  554. icon_name: 'edit-delete-symbolic',
  555. pixel_size: 16,
  556. visible: true,
  557. }),
  558. tooltip_text: _('Remove'),
  559. valign: Gtk.Align.CENTER,
  560. vexpand: true,
  561. visible: true,
  562. });
  563. deleteButton.connect('clicked', this._onDeleteCommand.bind(this));
  564. deleteButton.get_accessible().set_name(_('Remove'));
  565. row.get_child().attach(deleteButton, 3, 0, 1, 2);
  566. this.command_list.add(row);
  567. }
  568. _onEditCommand(widget) {
  569. if (this._commandEditor === undefined) {
  570. this._commandEditor = new CommandEditor({
  571. modal: true,
  572. transient_for: this.get_toplevel(),
  573. use_header_bar: true,
  574. });
  575. this._commandEditor.connect(
  576. 'response',
  577. this._onSaveCommand.bind(this)
  578. );
  579. this._commandEditor.resize(1, 1);
  580. }
  581. if (widget instanceof Gtk.Button) {
  582. const row = widget.get_ancestor(Gtk.ListBoxRow.$gtype);
  583. const uuid = row.get_name();
  584. this._commandEditor.uuid = uuid;
  585. this._commandEditor.command_name = this._commands[uuid].name;
  586. this._commandEditor.command_line = this._commands[uuid].command;
  587. } else {
  588. this._commandEditor.uuid = GLib.uuid_string_random();
  589. this._commandEditor.command_name = '';
  590. this._commandEditor.command_line = '';
  591. }
  592. this._commandEditor.present();
  593. }
  594. _storeCommands() {
  595. const variant = {};
  596. for (const [uuid, command] of Object.entries(this._commands))
  597. variant[uuid] = new GLib.Variant('a{ss}', command);
  598. this.pluginSettings('runcommand').set_value(
  599. 'command-list',
  600. new GLib.Variant('a{sv}', variant)
  601. );
  602. }
  603. _onDeleteCommand(button) {
  604. const row = button.get_ancestor(Gtk.ListBoxRow.$gtype);
  605. delete this._commands[row.get_name()];
  606. row.destroy();
  607. this._storeCommands();
  608. }
  609. _onSaveCommand(dialog, response_id) {
  610. if (response_id === Gtk.ResponseType.ACCEPT) {
  611. this._commands[dialog.uuid] = {
  612. name: dialog.command_name,
  613. command: dialog.command_line,
  614. };
  615. this._storeCommands();
  616. //
  617. let row = null;
  618. for (const child of this.command_list.get_children()) {
  619. if (child.get_name() === dialog.uuid) {
  620. row = child;
  621. break;
  622. }
  623. }
  624. if (row === null) {
  625. this._insertCommand(dialog.uuid);
  626. } else {
  627. row.set_name(dialog.uuid);
  628. row.title = dialog.command_name;
  629. row.subtitle = dialog.command_line;
  630. }
  631. }
  632. dialog.hide();
  633. }
  634. /**
  635. * Notification Settings
  636. */
  637. _notificationSettings() {
  638. const settings = this.pluginSettings('notification');
  639. settings.bind(
  640. 'send-notifications',
  641. this.notification_apps,
  642. 'sensitive',
  643. Gio.SettingsBindFlags.DEFAULT
  644. );
  645. // Separators & Sorting
  646. this.notification_list.set_header_func(rowSeparators);
  647. // Scroll with keyboard focus
  648. const notification_box = this.notification_page.get_child().get_child();
  649. notification_box.set_focus_vadjustment(this.notification_page.vadjustment);
  650. // Continue focus chain between lists
  651. this.notification_list.next = this.notification_apps;
  652. this.notification_apps.prev = this.notification_list;
  653. this.notification_apps.set_sort_func(titleSortFunc);
  654. this.notification_apps.set_header_func(rowSeparators);
  655. this._populateApplications(settings);
  656. }
  657. _toggleNotification(widget) {
  658. try {
  659. const row = widget.get_ancestor(Gtk.ListBoxRow.$gtype);
  660. const settings = this.pluginSettings('notification');
  661. let applications = {};
  662. try {
  663. applications = JSON.parse(settings.get_string('applications'));
  664. } catch {
  665. applications = {};
  666. }
  667. applications[row.title].enabled = !applications[row.title].enabled;
  668. row.widget.state = applications[row.title].enabled;
  669. settings.set_string('applications', JSON.stringify(applications));
  670. } catch (e) {
  671. logError(e);
  672. }
  673. }
  674. _populateApplications(settings) {
  675. const applications = this._queryApplications(settings);
  676. for (const name in applications) {
  677. const row = new SectionRow({
  678. gicon: Gio.Icon.new_for_string(applications[name].iconName),
  679. title: name,
  680. height_request: 48,
  681. widget: new Gtk.Switch({
  682. state: applications[name].enabled,
  683. margin_start: 12,
  684. margin_end: 12,
  685. halign: Gtk.Align.END,
  686. valign: Gtk.Align.CENTER,
  687. vexpand: true,
  688. visible: true,
  689. }),
  690. });
  691. row.widget.connect('notify::active', this._toggleNotification.bind(this));
  692. this.notification_apps.add(row);
  693. }
  694. }
  695. _queryApplications(settings) {
  696. let applications = {};
  697. try {
  698. applications = JSON.parse(settings.get_string('applications'));
  699. } catch {
  700. applications = {};
  701. }
  702. // Scan applications that statically declare to show notifications
  703. const ignoreId = 'org.gnome.Shell.Extensions.GSConnect.desktop';
  704. for (const appInfo of Gio.AppInfo.get_all()) {
  705. if (appInfo.get_id() === ignoreId)
  706. continue;
  707. if (!appInfo.get_boolean('X-GNOME-UsesNotifications'))
  708. continue;
  709. const appName = appInfo.get_name();
  710. if (appName === null || applications.hasOwnProperty(appName))
  711. continue;
  712. let icon = appInfo.get_icon();
  713. icon = (icon) ? icon.to_string() : 'application-x-executable';
  714. applications[appName] = {
  715. iconName: icon,
  716. enabled: true,
  717. };
  718. }
  719. settings.set_string('applications', JSON.stringify(applications));
  720. return applications;
  721. }
  722. /**
  723. * Telephony Settings
  724. */
  725. _telephonySettings() {
  726. // Continue focus chain between lists
  727. this.ringing_list.next = this.talking_list;
  728. this.talking_list.prev = this.ringing_list;
  729. this.ringing_list.set_header_func(rowSeparators);
  730. this.talking_list.set_header_func(rowSeparators);
  731. }
  732. /**
  733. * Keyboard Shortcuts
  734. */
  735. _keybindingSettings() {
  736. // Scroll with keyboard focus
  737. const shortcuts_box = this.shortcuts_page.get_child().get_child();
  738. shortcuts_box.set_focus_vadjustment(this.shortcuts_page.vadjustment);
  739. // Filter & Sort
  740. this.shortcuts_actions_list.set_filter_func(this._filterPluginKeybindings.bind(this));
  741. this.shortcuts_actions_list.set_header_func(rowSeparators);
  742. this.shortcuts_actions_list.set_sort_func(titleSortFunc);
  743. // Init
  744. for (const name in DEVICE_SHORTCUTS)
  745. this._addPluginKeybinding(name);
  746. this._setPluginKeybindings();
  747. // Watch for GAction and Keybinding changes
  748. this._actionAddedId = this.device.action_group.connect(
  749. 'action-added',
  750. () => this.shortcuts_actions_list.invalidate_filter()
  751. );
  752. this._actionRemovedId = this.device.action_group.connect(
  753. 'action-removed',
  754. () => this.shortcuts_actions_list.invalidate_filter()
  755. );
  756. this._keybindingsId = this.settings.connect(
  757. 'changed::keybindings',
  758. this._setPluginKeybindings.bind(this)
  759. );
  760. }
  761. _addPluginKeybinding(name) {
  762. const [icon_name, label] = DEVICE_SHORTCUTS[name];
  763. const widget = new Gtk.Label({
  764. label: _('Disabled'),
  765. visible: true,
  766. });
  767. widget.get_style_context().add_class('dim-label');
  768. const row = new SectionRow({
  769. height_request: 48,
  770. icon_name: icon_name,
  771. title: label,
  772. widget: widget,
  773. });
  774. row.icon_image.pixel_size = 16;
  775. row.action = name;
  776. this.shortcuts_actions_list.add(row);
  777. }
  778. _filterPluginKeybindings(row) {
  779. return this.device.action_group.has_action(row.action);
  780. }
  781. _setPluginKeybindings() {
  782. const keybindings = this.settings.get_value('keybindings').deepUnpack();
  783. this.shortcuts_actions_list.foreach(row => {
  784. if (keybindings[row.action]) {
  785. const accel = Gtk.accelerator_parse(keybindings[row.action]);
  786. row.widget.label = Gtk.accelerator_get_label(...accel);
  787. } else {
  788. row.widget.label = _('Disabled');
  789. }
  790. });
  791. }
  792. _onResetActionShortcuts(button) {
  793. const keybindings = this.settings.get_value('keybindings').deepUnpack();
  794. for (const action in keybindings) {
  795. // Don't reset remote command shortcuts
  796. if (!action.includes('::'))
  797. delete keybindings[action];
  798. }
  799. this.settings.set_value(
  800. 'keybindings',
  801. new GLib.Variant('a{ss}', keybindings)
  802. );
  803. }
  804. async _onShortcutRowActivated(box, row) {
  805. try {
  806. const keybindings = this.settings.get_value('keybindings').deepUnpack();
  807. let accel = keybindings[row.action] || null;
  808. accel = await Keybindings.getAccelerator(row.title, accel);
  809. if (accel)
  810. keybindings[row.action] = accel;
  811. else
  812. delete keybindings[row.action];
  813. this.settings.set_value(
  814. 'keybindings',
  815. new GLib.Variant('a{ss}', keybindings)
  816. );
  817. } catch (e) {
  818. logError(e);
  819. }
  820. }
  821. /**
  822. * Advanced Page
  823. */
  824. _advancedSettings() {
  825. // Scroll with keyboard focus
  826. const advanced_box = this.advanced_page.get_child().get_child();
  827. advanced_box.set_focus_vadjustment(this.advanced_page.vadjustment);
  828. // Sort & Separate
  829. this.plugin_list.set_header_func(rowSeparators);
  830. this.plugin_list.set_sort_func(titleSortFunc);
  831. this.experimental_list.set_header_func(rowSeparators);
  832. // Continue focus chain between lists
  833. this.plugin_list.next = this.experimental_list;
  834. this.experimental_list.prev = this.plugin_list;
  835. this._disabledPluginsId = this.settings.connect(
  836. 'changed::disabled-plugins',
  837. this._onPluginsChanged.bind(this)
  838. );
  839. this._supportedPluginsId = this.settings.connect(
  840. 'changed::supported-plugins',
  841. this._onPluginsChanged.bind(this)
  842. );
  843. this._onPluginsChanged(this.settings, null);
  844. for (const name of DEVICE_PLUGINS)
  845. this._addPlugin(name);
  846. }
  847. _onPluginsChanged(settings, key) {
  848. if (key === 'disabled-plugins' || this._disabledPlugins === undefined)
  849. this._disabledPlugins = settings.get_strv('disabled-plugins');
  850. if (key === 'supported-plugins' || this._supportedPlugins === undefined)
  851. this._supportedPlugins = settings.get_strv('supported-plugins');
  852. this._enabledPlugins = this._supportedPlugins.filter(name => {
  853. return !this._disabledPlugins.includes(name);
  854. });
  855. if (key !== null)
  856. this._updatePlugins();
  857. }
  858. _addPlugin(name) {
  859. const plugin = plugins[name];
  860. const row = new SectionRow({
  861. height_request: 48,
  862. title: plugin.Metadata.label,
  863. subtitle: plugin.Metadata.description || '',
  864. visible: this._supportedPlugins.includes(name),
  865. widget: new Gtk.Switch({
  866. active: this._enabledPlugins.includes(name),
  867. valign: Gtk.Align.CENTER,
  868. vexpand: true,
  869. visible: true,
  870. }),
  871. });
  872. row.widget.connect('notify::active', this._togglePlugin.bind(this));
  873. row.set_name(name);
  874. if (this.hasOwnProperty(name))
  875. this[name].visible = row.widget.active;
  876. this.plugin_list.add(row);
  877. }
  878. _updatePlugins(settings, key) {
  879. for (const row of this.plugin_list.get_children()) {
  880. const name = row.get_name();
  881. row.visible = this._supportedPlugins.includes(name);
  882. row.widget.active = this._enabledPlugins.includes(name);
  883. if (this.hasOwnProperty(name))
  884. this[name].visible = row.widget.active;
  885. }
  886. }
  887. _togglePlugin(widget) {
  888. try {
  889. const name = widget.get_ancestor(Gtk.ListBoxRow.$gtype).get_name();
  890. const index = this._disabledPlugins.indexOf(name);
  891. // Either add or remove the plugin from the disabled list
  892. if (index > -1)
  893. this._disabledPlugins.splice(index, 1);
  894. else
  895. this._disabledPlugins.push(name);
  896. this.settings.set_strv('disabled-plugins', this._disabledPlugins);
  897. } catch (e) {
  898. logError(e);
  899. }
  900. }
  901. });