service.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Gdk from 'gi://Gdk';
  5. import GdkPixbuf from 'gi://GdkPixbuf';
  6. import Gio from 'gi://Gio';
  7. import GLib from 'gi://GLib';
  8. import GObject from 'gi://GObject';
  9. import Gtk from 'gi://Gtk';
  10. import system from 'system';
  11. import Config from '../config.js';
  12. import {Panel, rowSeparators} from './device.js';
  13. import {Service} from '../utils/remote.js';
  14. /*
  15. * Header for support logs
  16. */
  17. const LOG_HEADER = new GLib.Bytes(`
  18. GSConnect: ${Config.PACKAGE_VERSION} (${Config.IS_USER ? 'user' : 'system'})
  19. GJS: ${system.version}
  20. Session: ${GLib.getenv('XDG_SESSION_TYPE')}
  21. OS: ${GLib.get_os_info('PRETTY_NAME')}
  22. --------------------------------------------------------------------------------
  23. `);
  24. /**
  25. * Generate a support log.
  26. *
  27. * @param {string} time - Start time as a string (24-hour notation)
  28. */
  29. async function generateSupportLog(time) {
  30. try {
  31. const [file, stream] = Gio.File.new_tmp('gsconnect.XXXXXX');
  32. const logFile = stream.get_output_stream();
  33. await new Promise((resolve, reject) => {
  34. logFile.write_bytes_async(LOG_HEADER, 0, null, (file, res) => {
  35. try {
  36. resolve(file.write_bytes_finish(res));
  37. } catch (e) {
  38. reject(e);
  39. }
  40. });
  41. });
  42. // FIXME: BSD???
  43. const proc = new Gio.Subprocess({
  44. flags: (Gio.SubprocessFlags.STDOUT_PIPE |
  45. Gio.SubprocessFlags.STDERR_MERGE),
  46. argv: ['journalctl', '--no-host', '--since', time],
  47. });
  48. proc.init(null);
  49. logFile.splice_async(
  50. proc.get_stdout_pipe(),
  51. Gio.OutputStreamSpliceFlags.CLOSE_TARGET,
  52. GLib.PRIORITY_DEFAULT,
  53. null,
  54. (source, res) => {
  55. try {
  56. source.splice_finish(res);
  57. } catch (e) {
  58. logError(e);
  59. }
  60. }
  61. );
  62. await new Promise((resolve, reject) => {
  63. proc.wait_check_async(null, (proc, res) => {
  64. try {
  65. resolve(proc.wait_finish(res));
  66. } catch (e) {
  67. reject(e);
  68. }
  69. });
  70. });
  71. const uri = file.get_uri();
  72. Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
  73. } catch (e) {
  74. logError(e);
  75. }
  76. }
  77. /**
  78. * "Connect to..." Dialog
  79. */
  80. const ConnectDialog = GObject.registerClass({
  81. GTypeName: 'GSConnectConnectDialog',
  82. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/connect-dialog.ui',
  83. Children: [
  84. 'cancel-button', 'connect-button',
  85. 'lan-grid', 'lan-ip', 'lan-port',
  86. ],
  87. }, class ConnectDialog extends Gtk.Dialog {
  88. _init(params = {}) {
  89. super._init(Object.assign({
  90. use_header_bar: true,
  91. }, params));
  92. }
  93. vfunc_response(response_id) {
  94. if (response_id === Gtk.ResponseType.OK) {
  95. try {
  96. let address;
  97. // Lan host/port entered
  98. if (this.lan_ip.text) {
  99. const host = this.lan_ip.text;
  100. const port = this.lan_port.value;
  101. address = GLib.Variant.new_string(`lan://${host}:${port}`);
  102. } else {
  103. return false;
  104. }
  105. this.application.activate_action('connect', address);
  106. } catch (e) {
  107. logError(e);
  108. }
  109. }
  110. this.destroy();
  111. return false;
  112. }
  113. });
  114. export const Window = GObject.registerClass({
  115. GTypeName: 'GSConnectPreferencesWindow',
  116. Properties: {
  117. 'display-mode': GObject.ParamSpec.string(
  118. 'display-mode',
  119. 'Display Mode',
  120. 'Display devices in either the Panel or User Menu',
  121. GObject.ParamFlags.READWRITE,
  122. null
  123. ),
  124. },
  125. Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-window.ui',
  126. Children: [
  127. // HeaderBar
  128. 'headerbar', 'stack',
  129. 'infobar_discoverable', 'infobar_openssl',
  130. 'service-menu', 'service-edit', 'refresh-button',
  131. 'device-menu', 'prev-button',
  132. // Popover
  133. 'rename-popover', 'rename', 'rename-label', 'rename-entry', 'rename-submit',
  134. // Focus Box
  135. 'service-window', 'service-box',
  136. // Device List
  137. 'device-list', 'device-list-spinner', 'device-list-placeholder',
  138. ],
  139. }, class PreferencesWindow extends Gtk.ApplicationWindow {
  140. _init(params = {}) {
  141. super._init(params);
  142. // Service Settings
  143. this.settings = new Gio.Settings({
  144. settings_schema: Config.GSCHEMA.lookup(
  145. 'org.gnome.Shell.Extensions.GSConnect',
  146. true
  147. ),
  148. });
  149. // Service Proxy
  150. this.service = new Service();
  151. this._deviceAddedId = this.service.connect(
  152. 'device-added',
  153. this._onDeviceAdded.bind(this)
  154. );
  155. this._deviceRemovedId = this.service.connect(
  156. 'device-removed',
  157. this._onDeviceRemoved.bind(this)
  158. );
  159. this._serviceChangedId = this.service.connect(
  160. 'notify::active',
  161. this._onServiceChanged.bind(this)
  162. );
  163. // HeaderBar (Service Name)
  164. this.headerbar.title = this.settings.get_string('name');
  165. this.rename_entry.text = this.headerbar.title;
  166. // Scroll with keyboard focus
  167. this.service_box.set_focus_vadjustment(this.service_window.vadjustment);
  168. // Device List
  169. this.device_list.set_header_func(rowSeparators);
  170. // Discoverable InfoBar
  171. this.settings.bind(
  172. 'discoverable',
  173. this.infobar_discoverable,
  174. 'reveal-child',
  175. Gio.SettingsBindFlags.INVERT_BOOLEAN
  176. );
  177. this.add_action(this.settings.create_action('discoverable'));
  178. // OpenSSL-missing infobar
  179. this.settings.bind(
  180. 'missing-openssl',
  181. this.infobar_openssl,
  182. 'reveal-child',
  183. Gio.SettingsBindFlags.DEFAULT
  184. );
  185. this.add_action(this.settings.create_action('missing-openssl'));
  186. // Application Menu
  187. this._initMenu();
  188. // Setting: Keep Alive When Locked
  189. this.add_action(this.settings.create_action('keep-alive-when-locked'));
  190. // Broadcast automatically every 5 seconds if there are no devices yet
  191. this._refreshSource = GLib.timeout_add_seconds(
  192. GLib.PRIORITY_DEFAULT,
  193. 5,
  194. this._refresh.bind(this)
  195. );
  196. // Restore window size/maximized/position
  197. this._restoreGeometry();
  198. // Prime the service
  199. this._initService();
  200. }
  201. get display_mode() {
  202. if (this.settings.get_boolean('show-indicators'))
  203. return 'panel';
  204. return 'user-menu';
  205. }
  206. set display_mode(mode) {
  207. this.settings.set_boolean('show-indicators', (mode === 'panel'));
  208. }
  209. vfunc_delete_event(event) {
  210. if (this.service) {
  211. this.service.disconnect(this._deviceAddedId);
  212. this.service.disconnect(this._deviceRemovedId);
  213. this.service.disconnect(this._serviceChangedId);
  214. this.service.destroy();
  215. this.service = null;
  216. }
  217. this._saveGeometry();
  218. GLib.source_remove(this._refreshSource);
  219. return false;
  220. }
  221. async _initService() {
  222. try {
  223. this.refresh_button.grab_focus();
  224. this._onServiceChanged(this.service, null);
  225. await this.service.reload();
  226. } catch (e) {
  227. logError(e, 'GSConnect');
  228. }
  229. }
  230. _initMenu() {
  231. // Panel/User Menu mode
  232. const displayMode = new Gio.PropertyAction({
  233. name: 'display-mode',
  234. property_name: 'display-mode',
  235. object: this,
  236. });
  237. this.add_action(displayMode);
  238. // About Dialog
  239. const aboutDialog = new Gio.SimpleAction({name: 'about'});
  240. aboutDialog.connect('activate', this._aboutDialog.bind(this));
  241. this.add_action(aboutDialog);
  242. // "Connect to..." Dialog
  243. const connectDialog = new Gio.SimpleAction({name: 'connect'});
  244. connectDialog.connect('activate', this._connectDialog.bind(this));
  245. this.add_action(connectDialog);
  246. // "Generate Support Log" GAction
  247. const generateSupportLog = new Gio.SimpleAction({name: 'support-log'});
  248. generateSupportLog.connect('activate', this._generateSupportLog.bind(this));
  249. this.add_action(generateSupportLog);
  250. // "Help" GAction
  251. const help = new Gio.SimpleAction({name: 'help'});
  252. help.connect('activate', this._help);
  253. this.add_action(help);
  254. }
  255. _refresh() {
  256. const missing_openssl = this.settings.get_boolean('missing-openssl');
  257. const no_devices = this.service.active && this.device_list.get_children().length < 1;
  258. if (missing_openssl || no_devices) {
  259. this.device_list_spinner.active = true;
  260. this.service.activate_action('refresh', null);
  261. } else {
  262. this.device_list_spinner.active = false;
  263. }
  264. return GLib.SOURCE_CONTINUE;
  265. }
  266. /*
  267. * Window State
  268. */
  269. _restoreGeometry() {
  270. this._windowState = new Gio.Settings({
  271. settings_schema: Config.GSCHEMA.lookup(
  272. 'org.gnome.Shell.Extensions.GSConnect.WindowState',
  273. true
  274. ),
  275. path: '/org/gnome/shell/extensions/gsconnect/preferences/',
  276. });
  277. // Size
  278. const [width, height] = this._windowState.get_value('window-size').deepUnpack();
  279. if (width && height)
  280. this.set_default_size(width, height);
  281. // Maximized State
  282. if (this._windowState.get_boolean('window-maximized'))
  283. this.maximize();
  284. }
  285. _saveGeometry() {
  286. const state = this.get_window().get_state();
  287. // Maximized State
  288. const maximized = (state & Gdk.WindowState.MAXIMIZED);
  289. this._windowState.set_boolean('window-maximized', maximized);
  290. // Leave the size at the value before maximizing
  291. if (maximized || (state & Gdk.WindowState.FULLSCREEN))
  292. return;
  293. // Size
  294. const size = this.get_size();
  295. this._windowState.set_value('window-size', new GLib.Variant('(ii)', size));
  296. }
  297. /**
  298. * About Dialog
  299. */
  300. _aboutDialog() {
  301. if (this._about === undefined) {
  302. this._about = new Gtk.AboutDialog({
  303. application: Gio.Application.get_default(),
  304. authors: [
  305. 'Andy Holmes <andrew.g.r.holmes@gmail.com>',
  306. 'Bertrand Lacoste <getzze@gmail.com>',
  307. 'Frank Dana <ferdnyc@gmail.com>',
  308. ],
  309. comments: _('A complete KDE Connect implementation for GNOME'),
  310. logo: GdkPixbuf.Pixbuf.new_from_resource_at_scale(
  311. '/org/gnome/Shell/Extensions/GSConnect/icons/org.gnome.Shell.Extensions.GSConnect.svg',
  312. 128,
  313. 128,
  314. true
  315. ),
  316. program_name: 'GSConnect',
  317. // TRANSLATORS: eg. 'Translator Name <your.email@domain.com>'
  318. translator_credits: _('translator-credits'),
  319. version: Config.PACKAGE_VERSION.toString(),
  320. website: Config.PACKAGE_URL,
  321. license_type: Gtk.License.GPL_2_0,
  322. modal: true,
  323. transient_for: this,
  324. });
  325. // Persist
  326. this._about.connect('response', (dialog) => dialog.hide_on_delete());
  327. this._about.connect('delete-event', (dialog) => dialog.hide_on_delete());
  328. }
  329. this._about.present();
  330. }
  331. /**
  332. * Connect to..." Dialog
  333. */
  334. _connectDialog() {
  335. new ConnectDialog({
  336. application: Gio.Application.get_default(),
  337. modal: true,
  338. transient_for: this,
  339. });
  340. }
  341. /*
  342. * "Generate Support Log" GAction
  343. */
  344. _generateSupportLog() {
  345. const dialog = new Gtk.MessageDialog({
  346. text: _('Generate Support Log'),
  347. secondary_text: _('Debug messages are being logged. Take any steps necessary to reproduce a problem then review the log.'),
  348. });
  349. dialog.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
  350. dialog.add_button(_('Review Log'), Gtk.ResponseType.OK);
  351. // Enable debug logging and mark the current time
  352. this.settings.set_boolean('debug', true);
  353. const now = GLib.DateTime.new_now_local().format('%R');
  354. dialog.connect('response', (dialog, response_id) => {
  355. // Disable debug logging and destroy the dialog
  356. this.settings.set_boolean('debug', false);
  357. dialog.destroy();
  358. // Only generate a log if instructed
  359. if (response_id === Gtk.ResponseType.OK)
  360. generateSupportLog(now);
  361. });
  362. dialog.show_all();
  363. }
  364. _validateName(name) {
  365. // None of the forbidden characters and at least one non-whitespace
  366. if (name.trim() && /^[^"',;:.!?()[\]<>]{1,32}$/.test(name))
  367. return true;
  368. const dialog = new Gtk.MessageDialog({
  369. text: _('Invalid Device Name'),
  370. // TRANSLATOR: %s is a list of forbidden characters
  371. secondary_text: _('Device name must not contain any of %s ' +
  372. 'and have a length of 1-32 characters')
  373. .format('<b><tt>^"\',;:.!?()[]&lt;&gt;</tt></b>'),
  374. secondary_use_markup: true,
  375. buttons: Gtk.ButtonsType.OK,
  376. modal: true,
  377. transient_for: this,
  378. });
  379. dialog.connect('response', (dialog) => dialog.destroy());
  380. dialog.show_all();
  381. return false;
  382. }
  383. /*
  384. * "Help" GAction
  385. */
  386. _help(action, parameter) {
  387. const uri = `${Config.PACKAGE_URL}/wiki/Help`;
  388. Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
  389. }
  390. /*
  391. * HeaderBar Callbacks
  392. */
  393. _onPrevious(button, event) {
  394. // HeaderBar (Service)
  395. this.prev_button.visible = false;
  396. this.device_menu.visible = false;
  397. this.refresh_button.visible = true;
  398. this.service_edit.visible = true;
  399. this.service_menu.visible = true;
  400. this.headerbar.title = this.settings.get_string('name');
  401. this.headerbar.subtitle = null;
  402. // Panel
  403. this.stack.visible_child_name = 'service';
  404. this._setDeviceMenu();
  405. }
  406. _onEditServiceName(button, event) {
  407. this.rename_entry.text = this.headerbar.title;
  408. this.rename_entry.has_focus = true;
  409. }
  410. _onSetServiceName(widget) {
  411. if (this._validateName(this.rename_entry.text)) {
  412. this.headerbar.title = this.rename_entry.text;
  413. this.settings.set_string('name', this.rename_entry.text);
  414. }
  415. this.service_edit.active = false;
  416. this.service_edit.grab_focus();
  417. }
  418. _onRetryOpenssl(button, event) {
  419. this.settings.set_boolean('enabled', false);
  420. GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT_IDLE, 2, () => {
  421. this.settings.set_boolean('enabled', true);
  422. return GLib.SOURCE_REMOVE;
  423. });
  424. }
  425. /*
  426. * Context Switcher
  427. */
  428. _getTypeLabel(device) {
  429. switch (device.type) {
  430. case 'laptop':
  431. return _('Laptop');
  432. case 'phone':
  433. return _('Smartphone');
  434. case 'tablet':
  435. return _('Tablet');
  436. case 'tv':
  437. return _('Television');
  438. default:
  439. return _('Desktop');
  440. }
  441. }
  442. _setDeviceMenu(panel = null) {
  443. this.device_menu.insert_action_group('device', null);
  444. this.device_menu.insert_action_group('settings', null);
  445. this.device_menu.set_menu_model(null);
  446. if (panel === null)
  447. return;
  448. this.device_menu.insert_action_group('device', panel.device.action_group);
  449. this.device_menu.insert_action_group('settings', panel.actions);
  450. this.device_menu.set_menu_model(panel.menu);
  451. }
  452. _onDeviceChanged(statusLabel, device, pspec) {
  453. switch (false) {
  454. case device.paired:
  455. statusLabel.label = _('Unpaired');
  456. break;
  457. case device.connected:
  458. statusLabel.label = _('Disconnected');
  459. break;
  460. default:
  461. statusLabel.label = _('Connected');
  462. }
  463. }
  464. _createDeviceRow(device) {
  465. const row = new Gtk.ListBoxRow({
  466. height_request: 52,
  467. selectable: false,
  468. visible: true,
  469. });
  470. row.set_name(device.id);
  471. const grid = new Gtk.Grid({
  472. column_spacing: 12,
  473. margin_left: 20,
  474. margin_right: 20,
  475. margin_bottom: 8,
  476. margin_top: 8,
  477. visible: true,
  478. });
  479. row.add(grid);
  480. const icon = new Gtk.Image({
  481. gicon: new Gio.ThemedIcon({name: device.icon_name}),
  482. icon_size: Gtk.IconSize.BUTTON,
  483. visible: true,
  484. });
  485. grid.attach(icon, 0, 0, 1, 1);
  486. const title = new Gtk.Label({
  487. halign: Gtk.Align.START,
  488. hexpand: true,
  489. valign: Gtk.Align.CENTER,
  490. vexpand: true,
  491. visible: true,
  492. });
  493. grid.attach(title, 1, 0, 1, 1);
  494. const status = new Gtk.Label({
  495. halign: Gtk.Align.END,
  496. hexpand: true,
  497. valign: Gtk.Align.CENTER,
  498. vexpand: true,
  499. visible: true,
  500. });
  501. grid.attach(status, 2, 0, 1, 1);
  502. // Keep name up to date
  503. device.bind_property(
  504. 'name',
  505. title,
  506. 'label',
  507. GObject.BindingFlags.SYNC_CREATE
  508. );
  509. // Keep status up to date
  510. device.connect(
  511. 'notify::connected',
  512. this._onDeviceChanged.bind(null, status)
  513. );
  514. device.connect(
  515. 'notify::paired',
  516. this._onDeviceChanged.bind(null, status)
  517. );
  518. this._onDeviceChanged(status, device, null);
  519. return row;
  520. }
  521. _onDeviceAdded(service, device) {
  522. try {
  523. if (!this.stack.get_child_by_name(device.id)) {
  524. // Add the device preferences
  525. const prefs = new Panel(device);
  526. this.stack.add_titled(prefs, device.id, device.name);
  527. // Add a row to the device list
  528. prefs.row = this._createDeviceRow(device);
  529. this.device_list.add(prefs.row);
  530. }
  531. } catch (e) {
  532. logError(e);
  533. }
  534. }
  535. _onDeviceRemoved(service, device) {
  536. try {
  537. const prefs = this.stack.get_child_by_name(device.id);
  538. if (prefs === null)
  539. return;
  540. if (prefs === this.stack.get_visible_child())
  541. this._onPrevious();
  542. prefs.row.destroy();
  543. prefs.row = null;
  544. prefs.dispose();
  545. prefs.destroy();
  546. } catch (e) {
  547. logError(e);
  548. }
  549. }
  550. _onDeviceSelected(box, row) {
  551. try {
  552. if (row === null)
  553. return this._onPrevious();
  554. // Transition the panel
  555. const name = row.get_name();
  556. const prefs = this.stack.get_child_by_name(name);
  557. this.stack.visible_child = prefs;
  558. this._setDeviceMenu(prefs);
  559. // HeaderBar (Device)
  560. this.refresh_button.visible = false;
  561. this.service_edit.visible = false;
  562. this.service_menu.visible = false;
  563. this.prev_button.visible = true;
  564. this.device_menu.visible = true;
  565. this.headerbar.title = prefs.device.name;
  566. this.headerbar.subtitle = this._getTypeLabel(prefs.device);
  567. } catch (e) {
  568. logError(e);
  569. }
  570. }
  571. _onServiceChanged(service, pspec) {
  572. if (this.service.active)
  573. this.device_list_placeholder.label = _('Searching for devices…');
  574. else
  575. this.device_list_placeholder.label = _('Waiting for service…');
  576. }
  577. });