// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gdk from 'gi://Gdk'; import GdkPixbuf from 'gi://GdkPixbuf'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Gtk from 'gi://Gtk'; import system from 'system'; import Config from '../config.js'; import {Panel, rowSeparators} from './device.js'; import {Service} from '../utils/remote.js'; /* * Header for support logs */ const LOG_HEADER = new GLib.Bytes(` GSConnect: ${Config.PACKAGE_VERSION} (${Config.IS_USER ? 'user' : 'system'}) GJS: ${system.version} Session: ${GLib.getenv('XDG_SESSION_TYPE')} OS: ${GLib.get_os_info('PRETTY_NAME')} -------------------------------------------------------------------------------- `); /** * Generate a support log. * * @param {string} time - Start time as a string (24-hour notation) */ async function generateSupportLog(time) { try { const [file, stream] = Gio.File.new_tmp('gsconnect.XXXXXX'); const logFile = stream.get_output_stream(); await new Promise((resolve, reject) => { logFile.write_bytes_async(LOG_HEADER, 0, null, (file, res) => { try { resolve(file.write_bytes_finish(res)); } catch (e) { reject(e); } }); }); // FIXME: BSD??? const proc = new Gio.Subprocess({ flags: (Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_MERGE), argv: ['journalctl', '--no-host', '--since', time], }); proc.init(null); logFile.splice_async( proc.get_stdout_pipe(), Gio.OutputStreamSpliceFlags.CLOSE_TARGET, GLib.PRIORITY_DEFAULT, null, (source, res) => { try { source.splice_finish(res); } catch (e) { logError(e); } } ); await new Promise((resolve, reject) => { proc.wait_check_async(null, (proc, res) => { try { resolve(proc.wait_finish(res)); } catch (e) { reject(e); } }); }); const uri = file.get_uri(); Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null); } catch (e) { logError(e); } } /** * "Connect to..." Dialog */ const ConnectDialog = GObject.registerClass({ GTypeName: 'GSConnectConnectDialog', Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/connect-dialog.ui', Children: [ 'cancel-button', 'connect-button', 'lan-grid', 'lan-ip', 'lan-port', ], }, class ConnectDialog extends Gtk.Dialog { _init(params = {}) { super._init(Object.assign({ use_header_bar: true, }, params)); } vfunc_response(response_id) { if (response_id === Gtk.ResponseType.OK) { try { let address; // Lan host/port entered if (this.lan_ip.text) { const host = this.lan_ip.text; const port = this.lan_port.value; address = GLib.Variant.new_string(`lan://${host}:${port}`); } else { return false; } this.application.activate_action('connect', address); } catch (e) { logError(e); } } this.destroy(); return false; } }); export const Window = GObject.registerClass({ GTypeName: 'GSConnectPreferencesWindow', Properties: { 'display-mode': GObject.ParamSpec.string( 'display-mode', 'Display Mode', 'Display devices in either the Panel or User Menu', GObject.ParamFlags.READWRITE, null ), }, Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-window.ui', Children: [ // HeaderBar 'headerbar', 'infobar', 'stack', 'service-menu', 'service-edit', 'refresh-button', 'device-menu', 'prev-button', // Popover 'rename-popover', 'rename', 'rename-label', 'rename-entry', 'rename-submit', // Focus Box 'service-window', 'service-box', // Device List 'device-list', 'device-list-spinner', 'device-list-placeholder', ], }, class PreferencesWindow extends Gtk.ApplicationWindow { _init(params = {}) { super._init(params); // Service Settings this.settings = new Gio.Settings({ settings_schema: Config.GSCHEMA.lookup( 'org.gnome.Shell.Extensions.GSConnect', true ), }); // Service Proxy this.service = new Service(); this._deviceAddedId = this.service.connect( 'device-added', this._onDeviceAdded.bind(this) ); this._deviceRemovedId = this.service.connect( 'device-removed', this._onDeviceRemoved.bind(this) ); this._serviceChangedId = this.service.connect( 'notify::active', this._onServiceChanged.bind(this) ); // HeaderBar (Service Name) this.headerbar.title = this.settings.get_string('name'); this.rename_entry.text = this.headerbar.title; // Scroll with keyboard focus this.service_box.set_focus_vadjustment(this.service_window.vadjustment); // Device List this.device_list.set_header_func(rowSeparators); // Discoverable InfoBar this.settings.bind( 'discoverable', this.infobar, 'reveal-child', Gio.SettingsBindFlags.INVERT_BOOLEAN ); this.add_action(this.settings.create_action('discoverable')); // Application Menu this._initMenu(); // Setting: Keep Alive When Locked this.add_action(this.settings.create_action('keep-alive-when-locked')); // Broadcast automatically every 5 seconds if there are no devices yet this._refreshSource = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, 5, this._refresh.bind(this) ); // Restore window size/maximized/position this._restoreGeometry(); // Prime the service this._initService(); } get display_mode() { if (this.settings.get_boolean('show-indicators')) return 'panel'; return 'user-menu'; } set display_mode(mode) { this.settings.set_boolean('show-indicators', (mode === 'panel')); } vfunc_delete_event(event) { if (this.service) { this.service.disconnect(this._deviceAddedId); this.service.disconnect(this._deviceRemovedId); this.service.disconnect(this._serviceChangedId); this.service.destroy(); this.service = null; } this._saveGeometry(); GLib.source_remove(this._refreshSource); return false; } async _initService() { try { this.refresh_button.grab_focus(); this._onServiceChanged(this.service, null); await this.service.reload(); } catch (e) { logError(e, 'GSConnect'); } } _initMenu() { // Panel/User Menu mode const displayMode = new Gio.PropertyAction({ name: 'display-mode', property_name: 'display-mode', object: this, }); this.add_action(displayMode); // About Dialog const aboutDialog = new Gio.SimpleAction({name: 'about'}); aboutDialog.connect('activate', this._aboutDialog.bind(this)); this.add_action(aboutDialog); // "Connect to..." Dialog const connectDialog = new Gio.SimpleAction({name: 'connect'}); connectDialog.connect('activate', this._connectDialog.bind(this)); this.add_action(connectDialog); // "Generate Support Log" GAction const generateSupportLog = new Gio.SimpleAction({name: 'support-log'}); generateSupportLog.connect('activate', this._generateSupportLog.bind(this)); this.add_action(generateSupportLog); // "Help" GAction const help = new Gio.SimpleAction({name: 'help'}); help.connect('activate', this._help); this.add_action(help); } _refresh() { if (this.service.active && this.device_list.get_children().length < 1) { this.device_list_spinner.active = true; this.service.activate_action('refresh', null); } else { this.device_list_spinner.active = false; } return GLib.SOURCE_CONTINUE; } /* * Window State */ _restoreGeometry() { this._windowState = new Gio.Settings({ settings_schema: Config.GSCHEMA.lookup( 'org.gnome.Shell.Extensions.GSConnect.WindowState', true ), path: '/org/gnome/shell/extensions/gsconnect/preferences/', }); // Size const [width, height] = this._windowState.get_value('window-size').deepUnpack(); if (width && height) this.set_default_size(width, height); // Maximized State if (this._windowState.get_boolean('window-maximized')) this.maximize(); } _saveGeometry() { const state = this.get_window().get_state(); // Maximized State const maximized = (state & Gdk.WindowState.MAXIMIZED); this._windowState.set_boolean('window-maximized', maximized); // Leave the size at the value before maximizing if (maximized || (state & Gdk.WindowState.FULLSCREEN)) return; // Size const size = this.get_size(); this._windowState.set_value('window-size', new GLib.Variant('(ii)', size)); } /** * About Dialog */ _aboutDialog() { if (this._about === undefined) { this._about = new Gtk.AboutDialog({ application: Gio.Application.get_default(), authors: [ 'Andy Holmes ', 'Bertrand Lacoste ', 'Frank Dana ', ], comments: _('A complete KDE Connect implementation for GNOME'), logo: GdkPixbuf.Pixbuf.new_from_resource_at_scale( '/org/gnome/Shell/Extensions/GSConnect/icons/org.gnome.Shell.Extensions.GSConnect.svg', 128, 128, true ), program_name: 'GSConnect', // TRANSLATORS: eg. 'Translator Name ' translator_credits: _('translator-credits'), version: Config.PACKAGE_VERSION.toString(), website: Config.PACKAGE_URL, license_type: Gtk.License.GPL_2_0, modal: true, transient_for: this, }); // Persist this._about.connect('response', (dialog) => dialog.hide_on_delete()); this._about.connect('delete-event', (dialog) => dialog.hide_on_delete()); } this._about.present(); } /** * Connect to..." Dialog */ _connectDialog() { new ConnectDialog({ application: Gio.Application.get_default(), modal: true, transient_for: this, }); } /* * "Generate Support Log" GAction */ _generateSupportLog() { const dialog = new Gtk.MessageDialog({ text: _('Generate Support Log'), secondary_text: _('Debug messages are being logged. Take any steps necessary to reproduce a problem then review the log.'), }); dialog.add_button(_('Cancel'), Gtk.ResponseType.CANCEL); dialog.add_button(_('Review Log'), Gtk.ResponseType.OK); // Enable debug logging and mark the current time this.settings.set_boolean('debug', true); const now = GLib.DateTime.new_now_local().format('%R'); dialog.connect('response', (dialog, response_id) => { // Disable debug logging and destroy the dialog this.settings.set_boolean('debug', false); dialog.destroy(); // Only generate a log if instructed if (response_id === Gtk.ResponseType.OK) generateSupportLog(now); }); dialog.show_all(); } /* * "Help" GAction */ _help(action, parameter) { const uri = `${Config.PACKAGE_URL}/wiki/Help`; Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null); } /* * HeaderBar Callbacks */ _onPrevious(button, event) { // HeaderBar (Service) this.prev_button.visible = false; this.device_menu.visible = false; this.refresh_button.visible = true; this.service_edit.visible = true; this.service_menu.visible = true; this.headerbar.title = this.settings.get_string('name'); this.headerbar.subtitle = null; // Panel this.stack.visible_child_name = 'service'; this._setDeviceMenu(); } _onEditServiceName(button, event) { this.rename_entry.text = this.headerbar.title; this.rename_entry.has_focus = true; } _onSetServiceName(widget) { if (this.rename_entry.text.length) { this.headerbar.title = this.rename_entry.text; this.settings.set_string('name', this.rename_entry.text); } this.service_edit.active = false; this.service_edit.grab_focus(); } /* * Context Switcher */ _getTypeLabel(device) { switch (device.type) { case 'laptop': return _('Laptop'); case 'phone': return _('Smartphone'); case 'tablet': return _('Tablet'); case 'tv': return _('Television'); default: return _('Desktop'); } } _setDeviceMenu(panel = null) { this.device_menu.insert_action_group('device', null); this.device_menu.insert_action_group('settings', null); this.device_menu.set_menu_model(null); if (panel === null) return; this.device_menu.insert_action_group('device', panel.device.action_group); this.device_menu.insert_action_group('settings', panel.actions); this.device_menu.set_menu_model(panel.menu); } _onDeviceChanged(statusLabel, device, pspec) { switch (false) { case device.paired: statusLabel.label = _('Unpaired'); break; case device.connected: statusLabel.label = _('Disconnected'); break; default: statusLabel.label = _('Connected'); } } _createDeviceRow(device) { const row = new Gtk.ListBoxRow({ height_request: 52, selectable: false, visible: true, }); row.set_name(device.id); const grid = new Gtk.Grid({ column_spacing: 12, margin_left: 20, margin_right: 20, margin_bottom: 8, margin_top: 8, visible: true, }); row.add(grid); const icon = new Gtk.Image({ gicon: new Gio.ThemedIcon({name: device.icon_name}), icon_size: Gtk.IconSize.BUTTON, visible: true, }); grid.attach(icon, 0, 0, 1, 1); const title = new Gtk.Label({ halign: Gtk.Align.START, hexpand: true, valign: Gtk.Align.CENTER, vexpand: true, visible: true, }); grid.attach(title, 1, 0, 1, 1); const status = new Gtk.Label({ halign: Gtk.Align.END, hexpand: true, valign: Gtk.Align.CENTER, vexpand: true, visible: true, }); grid.attach(status, 2, 0, 1, 1); // Keep name up to date device.bind_property( 'name', title, 'label', GObject.BindingFlags.SYNC_CREATE ); // Keep status up to date device.connect( 'notify::connected', this._onDeviceChanged.bind(null, status) ); device.connect( 'notify::paired', this._onDeviceChanged.bind(null, status) ); this._onDeviceChanged(status, device, null); return row; } _onDeviceAdded(service, device) { try { if (!this.stack.get_child_by_name(device.id)) { // Add the device preferences const prefs = new Panel(device); this.stack.add_titled(prefs, device.id, device.name); // Add a row to the device list prefs.row = this._createDeviceRow(device); this.device_list.add(prefs.row); } } catch (e) { logError(e); } } _onDeviceRemoved(service, device) { try { const prefs = this.stack.get_child_by_name(device.id); if (prefs === null) return; if (prefs === this.stack.get_visible_child()) this._onPrevious(); prefs.row.destroy(); prefs.row = null; prefs.dispose(); prefs.destroy(); } catch (e) { logError(e); } } _onDeviceSelected(box, row) { try { if (row === null) return this._onPrevious(); // Transition the panel const name = row.get_name(); const prefs = this.stack.get_child_by_name(name); this.stack.visible_child = prefs; this._setDeviceMenu(prefs); // HeaderBar (Device) this.refresh_button.visible = false; this.service_edit.visible = false; this.service_menu.visible = false; this.prev_button.visible = true; this.device_menu.visible = true; this.headerbar.title = prefs.device.name; this.headerbar.subtitle = this._getTypeLabel(prefs.device); } catch (e) { logError(e); } } _onServiceChanged(service, pspec) { if (this.service.active) this.device_list_placeholder.label = _('Searching for devicesā€¦'); else this.device_list_placeholder.label = _('Waiting for serviceā€¦'); } });