service.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gdk = imports.gi.Gdk;
  6. const GdkPixbuf = imports.gi.GdkPixbuf;
  7. const Gio = imports.gi.Gio;
  8. const GLib = imports.gi.GLib;
  9. const GObject = imports.gi.GObject;
  10. const Gtk = imports.gi.Gtk;
  11. const Config = imports.config;
  12. const Device = imports.preferences.device;
  13. const Remote = imports.utils.remote;
  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: ${imports.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. var 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. var 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', 'infobar', 'stack',
  129. 'service-menu', 'service-edit', 'refresh-button',
  130. 'device-menu', 'prev-button',
  131. // Popover
  132. 'rename-popover', 'rename', 'rename-label', 'rename-entry', 'rename-submit',
  133. // Focus Box
  134. 'service-window', 'service-box',
  135. // Device List
  136. 'device-list', 'device-list-spinner', 'device-list-placeholder',
  137. ],
  138. }, class PreferencesWindow extends Gtk.ApplicationWindow {
  139. _init(params = {}) {
  140. super._init(params);
  141. // Service Settings
  142. this.settings = new Gio.Settings({
  143. settings_schema: Config.GSCHEMA.lookup(
  144. 'org.gnome.Shell.Extensions.GSConnect',
  145. true
  146. ),
  147. });
  148. // Service Proxy
  149. this.service = new Remote.Service();
  150. this._deviceAddedId = this.service.connect(
  151. 'device-added',
  152. this._onDeviceAdded.bind(this)
  153. );
  154. this._deviceRemovedId = this.service.connect(
  155. 'device-removed',
  156. this._onDeviceRemoved.bind(this)
  157. );
  158. this._serviceChangedId = this.service.connect(
  159. 'notify::active',
  160. this._onServiceChanged.bind(this)
  161. );
  162. // HeaderBar (Service Name)
  163. this.headerbar.title = this.settings.get_string('name');
  164. this.rename_entry.text = this.headerbar.title;
  165. // Scroll with keyboard focus
  166. this.service_box.set_focus_vadjustment(this.service_window.vadjustment);
  167. // Device List
  168. this.device_list.set_header_func(Device.rowSeparators);
  169. // Discoverable InfoBar
  170. this.settings.bind(
  171. 'discoverable',
  172. this.infobar,
  173. 'reveal-child',
  174. Gio.SettingsBindFlags.INVERT_BOOLEAN
  175. );
  176. this.add_action(this.settings.create_action('discoverable'));
  177. // Application Menu
  178. this._initMenu();
  179. // Setting: Keep Alive When Locked
  180. this.add_action(this.settings.create_action('keep-alive-when-locked'));
  181. // Broadcast automatically every 5 seconds if there are no devices yet
  182. this._refreshSource = GLib.timeout_add_seconds(
  183. GLib.PRIORITY_DEFAULT,
  184. 5,
  185. this._refresh.bind(this)
  186. );
  187. // Restore window size/maximized/position
  188. this._restoreGeometry();
  189. // Prime the service
  190. this._initService();
  191. }
  192. get display_mode() {
  193. if (this.settings.get_boolean('show-indicators'))
  194. return 'panel';
  195. return 'user-menu';
  196. }
  197. set display_mode(mode) {
  198. this.settings.set_boolean('show-indicators', (mode === 'panel'));
  199. }
  200. vfunc_delete_event(event) {
  201. if (this.service) {
  202. this.service.disconnect(this._deviceAddedId);
  203. this.service.disconnect(this._deviceRemovedId);
  204. this.service.disconnect(this._serviceChangedId);
  205. this.service.destroy();
  206. this.service = null;
  207. }
  208. this._saveGeometry();
  209. GLib.source_remove(this._refreshSource);
  210. return false;
  211. }
  212. async _initService() {
  213. try {
  214. this.refresh_button.grab_focus();
  215. this._onServiceChanged(this.service, null);
  216. await this.service.reload();
  217. } catch (e) {
  218. logError(e, 'GSConnect');
  219. }
  220. }
  221. _initMenu() {
  222. // Panel/User Menu mode
  223. const displayMode = new Gio.PropertyAction({
  224. name: 'display-mode',
  225. property_name: 'display-mode',
  226. object: this,
  227. });
  228. this.add_action(displayMode);
  229. // About Dialog
  230. const aboutDialog = new Gio.SimpleAction({name: 'about'});
  231. aboutDialog.connect('activate', this._aboutDialog.bind(this));
  232. this.add_action(aboutDialog);
  233. // "Connect to..." Dialog
  234. const connectDialog = new Gio.SimpleAction({name: 'connect'});
  235. connectDialog.connect('activate', this._connectDialog.bind(this));
  236. this.add_action(connectDialog);
  237. // "Generate Support Log" GAction
  238. const generateSupportLog = new Gio.SimpleAction({name: 'support-log'});
  239. generateSupportLog.connect('activate', this._generateSupportLog.bind(this));
  240. this.add_action(generateSupportLog);
  241. // "Help" GAction
  242. const help = new Gio.SimpleAction({name: 'help'});
  243. help.connect('activate', this._help);
  244. this.add_action(help);
  245. }
  246. _refresh() {
  247. if (this.service.active && this.device_list.get_children().length < 1) {
  248. this.device_list_spinner.active = true;
  249. this.service.activate_action('refresh', null);
  250. } else {
  251. this.device_list_spinner.active = false;
  252. }
  253. return GLib.SOURCE_CONTINUE;
  254. }
  255. /*
  256. * Window State
  257. */
  258. _restoreGeometry() {
  259. this._windowState = new Gio.Settings({
  260. settings_schema: Config.GSCHEMA.lookup(
  261. 'org.gnome.Shell.Extensions.GSConnect.WindowState',
  262. true
  263. ),
  264. path: '/org/gnome/shell/extensions/gsconnect/preferences/',
  265. });
  266. // Size
  267. const [width, height] = this._windowState.get_value('window-size').deepUnpack();
  268. if (width && height)
  269. this.set_default_size(width, height);
  270. // Maximized State
  271. if (this._windowState.get_boolean('window-maximized'))
  272. this.maximize();
  273. }
  274. _saveGeometry() {
  275. const state = this.get_window().get_state();
  276. // Maximized State
  277. const maximized = (state & Gdk.WindowState.MAXIMIZED);
  278. this._windowState.set_boolean('window-maximized', maximized);
  279. // Leave the size at the value before maximizing
  280. if (maximized || (state & Gdk.WindowState.FULLSCREEN))
  281. return;
  282. // Size
  283. const size = this.get_size();
  284. this._windowState.set_value('window-size', new GLib.Variant('(ii)', size));
  285. }
  286. /**
  287. * About Dialog
  288. */
  289. _aboutDialog() {
  290. if (this._about === undefined) {
  291. this._about = new Gtk.AboutDialog({
  292. application: Gio.Application.get_default(),
  293. authors: [
  294. 'Andy Holmes <andrew.g.r.holmes@gmail.com>',
  295. 'Bertrand Lacoste <getzze@gmail.com>',
  296. 'Frank Dana <ferdnyc@gmail.com>',
  297. ],
  298. comments: _('A complete KDE Connect implementation for GNOME'),
  299. logo: GdkPixbuf.Pixbuf.new_from_resource_at_scale(
  300. '/org/gnome/Shell/Extensions/GSConnect/icons/org.gnome.Shell.Extensions.GSConnect.svg',
  301. 128,
  302. 128,
  303. true
  304. ),
  305. program_name: 'GSConnect',
  306. // TRANSLATORS: eg. 'Translator Name <your.email@domain.com>'
  307. translator_credits: _('translator-credits'),
  308. version: Config.PACKAGE_VERSION.toString(),
  309. website: Config.PACKAGE_URL,
  310. license_type: Gtk.License.GPL_2_0,
  311. modal: true,
  312. transient_for: this,
  313. });
  314. // Persist
  315. this._about.connect('response', (dialog) => dialog.hide_on_delete());
  316. this._about.connect('delete-event', (dialog) => dialog.hide_on_delete());
  317. }
  318. this._about.present();
  319. }
  320. /**
  321. * Connect to..." Dialog
  322. */
  323. _connectDialog() {
  324. new ConnectDialog({
  325. application: Gio.Application.get_default(),
  326. modal: true,
  327. transient_for: this,
  328. });
  329. }
  330. /*
  331. * "Generate Support Log" GAction
  332. */
  333. _generateSupportLog() {
  334. const dialog = new Gtk.MessageDialog({
  335. text: _('Generate Support Log'),
  336. secondary_text: _('Debug messages are being logged. Take any steps necessary to reproduce a problem then review the log.'),
  337. });
  338. dialog.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
  339. dialog.add_button(_('Review Log'), Gtk.ResponseType.OK);
  340. // Enable debug logging and mark the current time
  341. this.settings.set_boolean('debug', true);
  342. const now = GLib.DateTime.new_now_local().format('%R');
  343. dialog.connect('response', (dialog, response_id) => {
  344. // Disable debug logging and destroy the dialog
  345. this.settings.set_boolean('debug', false);
  346. dialog.destroy();
  347. // Only generate a log if instructed
  348. if (response_id === Gtk.ResponseType.OK)
  349. generateSupportLog(now);
  350. });
  351. dialog.show_all();
  352. }
  353. /*
  354. * "Help" GAction
  355. */
  356. _help(action, parameter) {
  357. const uri = `${Config.PACKAGE_URL}/wiki/Help`;
  358. Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
  359. }
  360. /*
  361. * HeaderBar Callbacks
  362. */
  363. _onPrevious(button, event) {
  364. // HeaderBar (Service)
  365. this.prev_button.visible = false;
  366. this.device_menu.visible = false;
  367. this.refresh_button.visible = true;
  368. this.service_edit.visible = true;
  369. this.service_menu.visible = true;
  370. this.headerbar.title = this.settings.get_string('name');
  371. this.headerbar.subtitle = null;
  372. // Panel
  373. this.stack.visible_child_name = 'service';
  374. this._setDeviceMenu();
  375. }
  376. _onEditServiceName(button, event) {
  377. this.rename_entry.text = this.headerbar.title;
  378. this.rename_entry.has_focus = true;
  379. }
  380. _onSetServiceName(widget) {
  381. if (this.rename_entry.text.length) {
  382. this.headerbar.title = this.rename_entry.text;
  383. this.settings.set_string('name', this.rename_entry.text);
  384. }
  385. this.service_edit.active = false;
  386. this.service_edit.grab_focus();
  387. }
  388. /*
  389. * Context Switcher
  390. */
  391. _getTypeLabel(device) {
  392. switch (device.type) {
  393. case 'laptop':
  394. return _('Laptop');
  395. case 'phone':
  396. return _('Smartphone');
  397. case 'tablet':
  398. return _('Tablet');
  399. case 'tv':
  400. return _('Television');
  401. default:
  402. return _('Desktop');
  403. }
  404. }
  405. _setDeviceMenu(panel = null) {
  406. this.device_menu.insert_action_group('device', null);
  407. this.device_menu.insert_action_group('settings', null);
  408. this.device_menu.set_menu_model(null);
  409. if (panel === null)
  410. return;
  411. this.device_menu.insert_action_group('device', panel.device.action_group);
  412. this.device_menu.insert_action_group('settings', panel.actions);
  413. this.device_menu.set_menu_model(panel.menu);
  414. }
  415. _onDeviceChanged(statusLabel, device, pspec) {
  416. switch (false) {
  417. case device.paired:
  418. statusLabel.label = _('Unpaired');
  419. break;
  420. case device.connected:
  421. statusLabel.label = _('Disconnected');
  422. break;
  423. default:
  424. statusLabel.label = _('Connected');
  425. }
  426. }
  427. _createDeviceRow(device) {
  428. const row = new Gtk.ListBoxRow({
  429. height_request: 52,
  430. selectable: false,
  431. visible: true,
  432. });
  433. row.set_name(device.id);
  434. const grid = new Gtk.Grid({
  435. column_spacing: 12,
  436. margin_left: 20,
  437. margin_right: 20,
  438. margin_bottom: 8,
  439. margin_top: 8,
  440. visible: true,
  441. });
  442. row.add(grid);
  443. const icon = new Gtk.Image({
  444. gicon: new Gio.ThemedIcon({name: device.icon_name}),
  445. icon_size: Gtk.IconSize.BUTTON,
  446. visible: true,
  447. });
  448. grid.attach(icon, 0, 0, 1, 1);
  449. const title = new Gtk.Label({
  450. halign: Gtk.Align.START,
  451. hexpand: true,
  452. valign: Gtk.Align.CENTER,
  453. vexpand: true,
  454. visible: true,
  455. });
  456. grid.attach(title, 1, 0, 1, 1);
  457. const status = new Gtk.Label({
  458. halign: Gtk.Align.END,
  459. hexpand: true,
  460. valign: Gtk.Align.CENTER,
  461. vexpand: true,
  462. visible: true,
  463. });
  464. grid.attach(status, 2, 0, 1, 1);
  465. // Keep name up to date
  466. device.bind_property(
  467. 'name',
  468. title,
  469. 'label',
  470. GObject.BindingFlags.SYNC_CREATE
  471. );
  472. // Keep status up to date
  473. device.connect(
  474. 'notify::connected',
  475. this._onDeviceChanged.bind(null, status)
  476. );
  477. device.connect(
  478. 'notify::paired',
  479. this._onDeviceChanged.bind(null, status)
  480. );
  481. this._onDeviceChanged(status, device, null);
  482. return row;
  483. }
  484. _onDeviceAdded(service, device) {
  485. try {
  486. if (!this.stack.get_child_by_name(device.id)) {
  487. // Add the device preferences
  488. const prefs = new Device.Panel(device);
  489. this.stack.add_titled(prefs, device.id, device.name);
  490. // Add a row to the device list
  491. prefs.row = this._createDeviceRow(device);
  492. this.device_list.add(prefs.row);
  493. }
  494. } catch (e) {
  495. logError(e);
  496. }
  497. }
  498. _onDeviceRemoved(service, device) {
  499. try {
  500. const prefs = this.stack.get_child_by_name(device.id);
  501. if (prefs === null)
  502. return;
  503. if (prefs === this.stack.get_visible_child())
  504. this._onPrevious();
  505. prefs.row.destroy();
  506. prefs.row = null;
  507. prefs.dispose();
  508. prefs.destroy();
  509. } catch (e) {
  510. logError(e);
  511. }
  512. }
  513. _onDeviceSelected(box, row) {
  514. try {
  515. if (row === null)
  516. return this._onPrevious();
  517. // Transition the panel
  518. const name = row.get_name();
  519. const prefs = this.stack.get_child_by_name(name);
  520. this.stack.visible_child = prefs;
  521. this._setDeviceMenu(prefs);
  522. // HeaderBar (Device)
  523. this.refresh_button.visible = false;
  524. this.service_edit.visible = false;
  525. this.service_menu.visible = false;
  526. this.prev_button.visible = true;
  527. this.device_menu.visible = true;
  528. this.headerbar.title = prefs.device.name;
  529. this.headerbar.subtitle = this._getTypeLabel(prefs.device);
  530. } catch (e) {
  531. logError(e);
  532. }
  533. }
  534. _onServiceChanged(service, pspec) {
  535. if (this.service.active)
  536. this.device_list_placeholder.label = _('Searching for devices…');
  537. else
  538. this.device_list_placeholder.label = _('Waiting for service…');
  539. }
  540. });