diff options
Diffstat (limited to '')
-rw-r--r-- | js/ui/components/__init__.js | 58 | ||||
-rw-r--r-- | js/ui/components/automountManager.js | 256 | ||||
-rw-r--r-- | js/ui/components/autorunManager.js | 345 | ||||
-rw-r--r-- | js/ui/components/keyring.js | 229 | ||||
-rw-r--r-- | js/ui/components/networkAgent.js | 877 | ||||
-rw-r--r-- | js/ui/components/polkitAgent.js | 471 | ||||
-rw-r--r-- | js/ui/components/telepathyClient.js | 1019 |
7 files changed, 3255 insertions, 0 deletions
diff --git a/js/ui/components/__init__.js b/js/ui/components/__init__.js new file mode 100644 index 0000000..7430013 --- /dev/null +++ b/js/ui/components/__init__.js @@ -0,0 +1,58 @@ +/* exported ComponentManager */ +const Main = imports.ui.main; + +var ComponentManager = class { + constructor() { + this._allComponents = {}; + this._enabledComponents = []; + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + let newEnabledComponents = Main.sessionMode.components; + + newEnabledComponents + .filter(name => !this._enabledComponents.includes(name)) + .forEach(name => this._enableComponent(name)); + + this._enabledComponents + .filter(name => !newEnabledComponents.includes(name)) + .forEach(name => this._disableComponent(name)); + + this._enabledComponents = newEnabledComponents; + } + + _importComponent(name) { + let module = imports.ui.components[name]; + return module.Component; + } + + _ensureComponent(name) { + let component = this._allComponents[name]; + if (component) + return component; + + if (Main.sessionMode.isLocked) + return null; + + let constructor = this._importComponent(name); + component = new constructor(); + this._allComponents[name] = component; + return component; + } + + _enableComponent(name) { + let component = this._ensureComponent(name); + if (component) + component.enable(); + } + + _disableComponent(name) { + let component = this._allComponents[name]; + if (component == null) + return; + component.disable(); + } +}; diff --git a/js/ui/components/automountManager.js b/js/ui/components/automountManager.js new file mode 100644 index 0000000..4c0c223 --- /dev/null +++ b/js/ui/components/automountManager.js @@ -0,0 +1,256 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Gio, GLib } = imports.gi; +const Params = imports.misc.params; + +const GnomeSession = imports.misc.gnomeSession; +const Main = imports.ui.main; +const ShellMountOperation = imports.ui.shellMountOperation; + +var GNOME_SESSION_AUTOMOUNT_INHIBIT = 16; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_ENABLE_AUTOMOUNT = 'automount'; + +var AUTORUN_EXPIRE_TIMEOUT_SECS = 10; + +var AutomountManager = class { + constructor() { + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + this._activeOperations = new Map(); + this._session = new GnomeSession.SessionManager(); + this._session.connectSignal('InhibitorAdded', + this._InhibitorsChanged.bind(this)); + this._session.connectSignal('InhibitorRemoved', + this._InhibitorsChanged.bind(this)); + this._inhibited = false; + + this._volumeMonitor = Gio.VolumeMonitor.get(); + } + + enable() { + this._volumeMonitor.connectObject( + 'volume-added', this._onVolumeAdded.bind(this), + 'volume-removed', this._onVolumeRemoved.bind(this), + 'drive-connected', this._onDriveConnected.bind(this), + 'drive-disconnected', this._onDriveDisconnected.bind(this), + 'drive-eject-button', this._onDriveEjectButton.bind(this), this); + + this._mountAllId = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._startupMountAll.bind(this)); + GLib.Source.set_name_by_id(this._mountAllId, '[gnome-shell] this._startupMountAll'); + } + + disable() { + this._volumeMonitor.disconnectObject(this); + + if (this._mountAllId > 0) { + GLib.source_remove(this._mountAllId); + this._mountAllId = 0; + } + } + + async _InhibitorsChanged(_object, _senderName, [_inhibitor]) { + try { + const [inhibited] = + await this._session.IsInhibitedAsync(GNOME_SESSION_AUTOMOUNT_INHIBIT); + this._inhibited = inhibited; + } catch (e) {} + } + + _startupMountAll() { + let volumes = this._volumeMonitor.get_volumes(); + volumes.forEach(volume => { + this._checkAndMountVolume(volume, { + checkSession: false, + useMountOp: false, + allowAutorun: false, + }); + }); + + this._mountAllId = 0; + return GLib.SOURCE_REMOVE; + } + + _onDriveConnected() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._session.SessionIsActive) + return; + + let player = global.display.get_sound_player(); + player.play_from_theme('device-added-media', + _("External drive connected"), + null); + } + + _onDriveDisconnected() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._session.SessionIsActive) + return; + + let player = global.display.get_sound_player(); + player.play_from_theme('device-removed-media', + _("External drive disconnected"), + null); + } + + _onDriveEjectButton(monitor, drive) { + // TODO: this code path is not tested, as the GVfs volume monitor + // doesn't emit this signal just yet. + if (!this._session.SessionIsActive) + return; + + // we force stop/eject in this case, so we don't have to pass a + // mount operation object + if (drive.can_stop()) { + drive.stop(Gio.MountUnmountFlags.FORCE, null, null, + (o, res) => { + try { + drive.stop_finish(res); + } catch (e) { + log(`Unable to stop the drive after drive-eject-button ${e.toString()}`); + } + }); + } else if (drive.can_eject()) { + drive.eject_with_operation(Gio.MountUnmountFlags.FORCE, null, null, + (o, res) => { + try { + drive.eject_with_operation_finish(res); + } catch (e) { + log(`Unable to eject the drive after drive-eject-button ${e.toString()}`); + } + }); + } + } + + _onVolumeAdded(monitor, volume) { + this._checkAndMountVolume(volume); + } + + _checkAndMountVolume(volume, params) { + params = Params.parse(params, { + checkSession: true, + useMountOp: true, + allowAutorun: true, + }); + + if (params.checkSession) { + // if we're not in the current ConsoleKit session, + // don't attempt automount + if (!this._session.SessionIsActive) + return; + } + + if (this._inhibited) + return; + + // Volume is already mounted, don't bother. + if (volume.get_mount()) + return; + + if (!this._settings.get_boolean(SETTING_ENABLE_AUTOMOUNT) || + !volume.should_automount() || + !volume.can_mount()) { + // allow the autorun to run anyway; this can happen if the + // mount gets added programmatically later, even if + // should_automount() or can_mount() are false, like for + // blank optical media. + this._allowAutorun(volume); + this._allowAutorunExpire(volume); + + return; + } + + if (params.useMountOp) { + let operation = new ShellMountOperation.ShellMountOperation(volume); + this._mountVolume(volume, operation, params.allowAutorun); + } else { + this._mountVolume(volume, null, params.allowAutorun); + } + } + + _mountVolume(volume, operation, allowAutorun) { + if (allowAutorun) + this._allowAutorun(volume); + + const mountOp = operation?.mountOp ?? null; + this._activeOperations.set(volume, operation); + + volume.mount(0, mountOp, null, + this._onVolumeMounted.bind(this)); + } + + _onVolumeMounted(volume, res) { + this._allowAutorunExpire(volume); + + try { + volume.mount_finish(res); + this._closeOperation(volume); + } catch (e) { + // FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks + // backend, see https://bugs.freedesktop.org/show_bug.cgi?id=51271 + // To reask the password if the user input was empty or wrong, we + // will check for corresponding error messages. However, these + // error strings are not unique for the cases in the comments below. + if (e.message.includes('No key available with this passphrase') || // cryptsetup + e.message.includes('No key available to unlock device') || // udisks (no password) + // libblockdev wrong password opening LUKS device + e.message.includes('Failed to activate device: Incorrect passphrase') || + // cryptsetup returns EINVAL in many cases, including wrong TCRYPT password/parameters + e.message.includes('Failed to load device\'s parameters: Invalid argument')) { + this._reaskPassword(volume); + } else { + if (e.message.includes('Compiled against a version of libcryptsetup that does not support the VeraCrypt PIM setting')) { + Main.notifyError(_("Unable to unlock volume"), + _("The installed udisks version does not support the PIM setting")); + } + + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log(`Unable to mount volume ${volume.get_name()}: ${e.toString()}`); + this._closeOperation(volume); + } + } + } + + _onVolumeRemoved(monitor, volume) { + if (volume._allowAutorunExpireId && volume._allowAutorunExpireId > 0) { + GLib.source_remove(volume._allowAutorunExpireId); + delete volume._allowAutorunExpireId; + } + } + + _reaskPassword(volume) { + let prevOperation = this._activeOperations.get(volume); + const existingDialog = prevOperation?.borrowDialog(); + let operation = + new ShellMountOperation.ShellMountOperation(volume, + { existingDialog }); + this._mountVolume(volume, operation); + } + + _closeOperation(volume) { + let operation = this._activeOperations.get(volume); + if (!operation) + return; + operation.close(); + this._activeOperations.delete(volume); + } + + _allowAutorun(volume) { + volume.allowAutorun = true; + } + + _allowAutorunExpire(volume) { + let id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTORUN_EXPIRE_TIMEOUT_SECS, () => { + volume.allowAutorun = false; + delete volume._allowAutorunExpireId; + return GLib.SOURCE_REMOVE; + }); + volume._allowAutorunExpireId = id; + GLib.Source.set_name_by_id(id, '[gnome-shell] volume.allowAutorun'); + } +}; +var Component = AutomountManager; diff --git a/js/ui/components/autorunManager.js b/js/ui/components/autorunManager.js new file mode 100644 index 0000000..d94be39 --- /dev/null +++ b/js/ui/components/autorunManager.js @@ -0,0 +1,345 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GObject, St } = imports.gi; + +const GnomeSession = imports.misc.gnomeSession; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +Gio._promisify(Gio.Mount.prototype, 'guess_content_type'); + +const { loadInterfaceXML } = imports.misc.fileUtils; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_DISABLE_AUTORUN = 'autorun-never'; +const SETTING_START_APP = 'autorun-x-content-start-app'; +const SETTING_IGNORE = 'autorun-x-content-ignore'; +const SETTING_OPEN_FOLDER = 'autorun-x-content-open-folder'; + +var AutorunSetting = { + RUN: 0, + IGNORE: 1, + FILES: 2, + ASK: 3, +}; + +// misc utils +function shouldAutorunMount(mount) { + let root = mount.get_root(); + let volume = mount.get_volume(); + + if (!volume || !volume.allowAutorun) + return false; + + if (root.is_native() && isMountRootHidden(root)) + return false; + + return true; +} + +function isMountRootHidden(root) { + let path = root.get_path(); + + // skip any mounts in hidden directory hierarchies + return path.includes('/.'); +} + +function isMountNonLocal(mount) { + // If the mount doesn't have an associated volume, that means it's + // an uninteresting filesystem. Most devices that we care about will + // have a mount, like media players and USB sticks. + let volume = mount.get_volume(); + if (volume == null) + return true; + + return volume.get_identifier("class") == "network"; +} + +function startAppForMount(app, mount) { + let files = []; + let root = mount.get_root(); + let retval = false; + + files.push(root); + + try { + retval = app.launch(files, + global.create_app_launch_context(0, -1)); + } catch (e) { + log(`Unable to launch the application ${app.get_name()}: ${e}`); + } + + return retval; +} + +const HotplugSnifferIface = loadInterfaceXML('org.gnome.Shell.HotplugSniffer'); +const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(HotplugSnifferIface); +function HotplugSniffer() { + return new HotplugSnifferProxy(Gio.DBus.session, + 'org.gnome.Shell.HotplugSniffer', + '/org/gnome/Shell/HotplugSniffer'); +} + +var ContentTypeDiscoverer = class { + constructor() { + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + } + + async guessContentTypes(mount) { + let autorunEnabled = !this._settings.get_boolean(SETTING_DISABLE_AUTORUN); + let shouldScan = autorunEnabled && !isMountNonLocal(mount); + + let contentTypes = []; + if (shouldScan) { + try { + contentTypes = await mount.guess_content_type(false, null); + } catch (e) { + log(`Unable to guess content types on added mount ${mount.get_name()}: ${e}`); + } + + if (contentTypes.length === 0) { + const root = mount.get_root(); + const hotplugSniffer = new HotplugSniffer(); + [contentTypes] = await hotplugSniffer.SniffURIAsync(root.get_uri()); + } + } + + // we're not interested in win32 software content types here + contentTypes = contentTypes.filter( + type => type !== 'x-content/win32-software'); + + const apps = []; + contentTypes.forEach(type => { + const app = Gio.app_info_get_default_for_type(type, false); + + if (app) + apps.push(app); + }); + + if (apps.length === 0) + apps.push(Gio.app_info_get_default_for_type('inode/directory', false)); + + return [apps, contentTypes]; + } +}; + +var AutorunManager = class { + constructor() { + this._session = new GnomeSession.SessionManager(); + this._volumeMonitor = Gio.VolumeMonitor.get(); + + this._dispatcher = new AutorunDispatcher(this); + } + + enable() { + this._volumeMonitor.connectObject( + 'mount-added', this._onMountAdded.bind(this), + 'mount-removed', this._onMountRemoved.bind(this), this); + } + + disable() { + this._volumeMonitor.disconnectObject(this); + } + + async _onMountAdded(monitor, mount) { + // don't do anything if our session is not the currently + // active one + if (!this._session.SessionIsActive) + return; + + const discoverer = new ContentTypeDiscoverer(); + const [apps, contentTypes] = await discoverer.guessContentTypes(mount); + this._dispatcher.addMount(mount, apps, contentTypes); + } + + _onMountRemoved(monitor, mount) { + this._dispatcher.removeMount(mount); + } +}; + +var AutorunDispatcher = class { + constructor(manager) { + this._manager = manager; + this._sources = []; + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + } + + _getAutorunSettingForType(contentType) { + let runApp = this._settings.get_strv(SETTING_START_APP); + if (runApp.includes(contentType)) + return AutorunSetting.RUN; + + let ignore = this._settings.get_strv(SETTING_IGNORE); + if (ignore.includes(contentType)) + return AutorunSetting.IGNORE; + + let openFiles = this._settings.get_strv(SETTING_OPEN_FOLDER); + if (openFiles.includes(contentType)) + return AutorunSetting.FILES; + + return AutorunSetting.ASK; + } + + _getSourceForMount(mount) { + let filtered = this._sources.filter(source => source.mount == mount); + + // we always make sure not to add two sources for the same + // mount in addMount(), so it's safe to assume filtered.length + // is always either 1 or 0. + if (filtered.length == 1) + return filtered[0]; + + return null; + } + + _addSource(mount, apps) { + // if we already have a source showing for this + // mount, return + if (this._getSourceForMount(mount)) + return; + + // add a new source + this._sources.push(new AutorunSource(this._manager, mount, apps)); + } + + addMount(mount, apps, contentTypes) { + // if autorun is disabled globally, return + if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN)) + return; + + // if the mount doesn't want to be autorun, return + if (!shouldAutorunMount(mount)) + return; + + let setting; + if (contentTypes.length > 0) + setting = this._getAutorunSettingForType(contentTypes[0]); + else + setting = AutorunSetting.ASK; + + // check at the settings for the first content type + // to see whether we should ask + if (setting == AutorunSetting.IGNORE) + return; // return right away + + let success = false; + let app = null; + + if (setting == AutorunSetting.RUN) + app = Gio.app_info_get_default_for_type(contentTypes[0], false); + else if (setting == AutorunSetting.FILES) + app = Gio.app_info_get_default_for_type('inode/directory', false); + + if (app) + success = startAppForMount(app, mount); + + // we fallback here also in case the settings did not specify 'ask', + // but we failed launching the default app or the default file manager + if (!success) + this._addSource(mount, apps); + } + + removeMount(mount) { + let source = this._getSourceForMount(mount); + + // if we aren't tracking this mount, don't do anything + if (!source) + return; + + // destroy the notification source + source.destroy(); + } +}; + +var AutorunSource = GObject.registerClass( +class AutorunSource extends MessageTray.Source { + _init(manager, mount, apps) { + super._init(mount.get_name()); + + this._manager = manager; + this.mount = mount; + this.apps = apps; + + this._notification = new AutorunNotification(this._manager, this); + + // add ourselves as a source, and popup the notification + Main.messageTray.add(this); + this.showNotification(this._notification); + } + + getIcon() { + return this.mount.get_icon(); + } + + _createPolicy() { + return new MessageTray.NotificationApplicationPolicy('org.gnome.Nautilus'); + } +}); + +var AutorunNotification = GObject.registerClass( +class AutorunNotification extends MessageTray.Notification { + _init(manager, source) { + super._init(source, source.title); + + this._manager = manager; + this._mount = source.mount; + } + + createBanner() { + let banner = new MessageTray.NotificationBanner(this); + + this.source.apps.forEach(app => { + let actor = this._buttonForApp(app); + + if (actor) + banner.addButton(actor); + }); + + return banner; + } + + _buttonForApp(app) { + let box = new St.BoxLayout({ + x_expand: true, + x_align: Clutter.ActorAlign.START, + }); + const icon = new St.Icon({ + gicon: app.get_icon(), + style_class: 'hotplug-notification-item-icon', + }); + box.add(icon); + + let label = new St.Bin({ + child: new St.Label({ + text: _("Open with %s").format(app.get_name()), + y_align: Clutter.ActorAlign.CENTER, + }), + }); + box.add(label); + + const button = new St.Button({ + child: box, + x_expand: true, + button_mask: St.ButtonMask.ONE, + style_class: 'hotplug-notification-item button', + }); + + button.connect('clicked', () => { + startAppForMount(app, this._mount); + this.destroy(); + }); + + return button; + } + + activate() { + super.activate(); + + let app = Gio.app_info_get_default_for_type('inode/directory', false); + startAppForMount(app, this._mount); + } +}); + +var Component = AutorunManager; diff --git a/js/ui/components/keyring.js b/js/ui/components/keyring.js new file mode 100644 index 0000000..cd7a81e --- /dev/null +++ b/js/ui/components/keyring.js @@ -0,0 +1,229 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gcr, Gio, GObject, Pango, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const CheckBox = imports.ui.checkBox; +const Util = imports.misc.util; + +var KeyringDialog = GObject.registerClass( +class KeyringDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'prompt-dialog' }); + + this.prompt = new Shell.KeyringPrompt(); + this.prompt.connect('show-password', this._onShowPassword.bind(this)); + this.prompt.connect('show-confirm', this._onShowConfirm.bind(this)); + this.prompt.connect('prompt-close', this._onHidePrompt.bind(this)); + + let content = new Dialog.MessageDialogContent(); + + this.prompt.bind_property('message', + content, 'title', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('description', + content, 'description', GObject.BindingFlags.SYNC_CREATE); + + let passwordBox = new St.BoxLayout({ + style_class: 'prompt-dialog-password-layout', + vertical: true, + }); + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._passwordEntry); + this._passwordEntry.clutter_text.connect('activate', this._onPasswordActivate.bind(this)); + this.prompt.bind_property('password-visible', + this._passwordEntry, 'visible', GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._passwordEntry); + + this._confirmEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._confirmEntry); + this._confirmEntry.clutter_text.connect('activate', this._onConfirmActivate.bind(this)); + this.prompt.bind_property('confirm-visible', + this._confirmEntry, 'visible', GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._confirmEntry); + + this.prompt.set_password_actor(this._passwordEntry.clutter_text); + this.prompt.set_confirm_actor(this._confirmEntry.clutter_text); + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + let syncCapsLockWarningVisibility = () => { + capsLockWarning.visible = + this.prompt.password_visible || this.prompt.confirm_visible; + }; + this.prompt.connect('notify::password-visible', syncCapsLockWarningVisibility); + this.prompt.connect('notify::confirm-visible', syncCapsLockWarningVisibility); + warningBox.add_child(capsLockWarning); + + let warning = new St.Label({ style_class: 'prompt-dialog-error-label' }); + warning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + warning.clutter_text.line_wrap = true; + this.prompt.bind_property('warning', + warning, 'text', GObject.BindingFlags.SYNC_CREATE); + this.prompt.connect('notify::warning-visible', () => { + warning.opacity = this.prompt.warning_visible ? 255 : 0; + }); + this.prompt.connect('notify::warning', () => { + if (this._passwordEntry && this.prompt.warning !== '') + Util.wiggle(this._passwordEntry); + }); + warningBox.add_child(warning); + + passwordBox.add_child(warningBox); + content.add_child(passwordBox); + + this._choice = new CheckBox.CheckBox(); + this.prompt.bind_property('choice-label', this._choice.getLabelActor(), + 'text', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('choice-chosen', this._choice, + 'checked', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL); + this.prompt.bind_property('choice-visible', this._choice, + 'visible', GObject.BindingFlags.SYNC_CREATE); + content.add_child(this._choice); + + this.contentLayout.add_child(content); + + this._cancelButton = this.addButton({ + label: '', + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }); + this._continueButton = this.addButton({ + label: '', + action: this._onContinueButton.bind(this), + default: true, + }); + + this.prompt.bind_property('cancel-label', this._cancelButton, 'label', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('continue-label', this._continueButton, 'label', GObject.BindingFlags.SYNC_CREATE); + } + + _updateSensitivity(sensitive) { + if (this._passwordEntry) + this._passwordEntry.reactive = sensitive; + + if (this._confirmEntry) + this._confirmEntry.reactive = sensitive; + + this._continueButton.can_focus = sensitive; + this._continueButton.reactive = sensitive; + } + + _ensureOpen() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (this.open()) + return true; + + // The above fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + + log('keyringPrompt: Failed to show modal dialog.' + + ' Dismissing prompt request'); + this.prompt.cancel(); + return false; + } + + _onShowPassword() { + this._ensureOpen(); + this._updateSensitivity(true); + this._passwordEntry.text = ''; + this._passwordEntry.grab_key_focus(); + } + + _onShowConfirm() { + this._ensureOpen(); + this._updateSensitivity(true); + this._confirmEntry.text = ''; + this._continueButton.grab_key_focus(); + } + + _onHidePrompt() { + this.close(); + } + + _onPasswordActivate() { + if (this.prompt.confirm_visible) + this._confirmEntry.grab_key_focus(); + else + this._onContinueButton(); + } + + _onConfirmActivate() { + this._onContinueButton(); + } + + _onContinueButton() { + this._updateSensitivity(false); + this.prompt.complete(); + } + + _onCancelButton() { + this.prompt.cancel(); + } +}); + +var KeyringDummyDialog = class { + constructor() { + this.prompt = new Shell.KeyringPrompt(); + this.prompt.connect('show-password', this._cancelPrompt.bind(this)); + this.prompt.connect('show-confirm', this._cancelPrompt.bind(this)); + } + + _cancelPrompt() { + this.prompt.cancel(); + } +}; + +var KeyringPrompter = GObject.registerClass( +class KeyringPrompter extends Gcr.SystemPrompter { + _init() { + super._init(); + this.connect('new-prompt', () => { + let dialog = this._enabled + ? new KeyringDialog() + : new KeyringDummyDialog(); + this._currentPrompt = dialog.prompt; + return this._currentPrompt; + }); + this._dbusId = null; + this._registered = false; + this._enabled = false; + this._currentPrompt = null; + } + + enable() { + if (!this._registered) { + this.register(Gio.DBus.session); + this._dbusId = Gio.DBus.session.own_name('org.gnome.keyring.SystemPrompter', + Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT, null, null); + this._registered = true; + } + this._enabled = true; + } + + disable() { + this._enabled = false; + + if (this.prompting) + this._currentPrompt.cancel(); + this._currentPrompt = null; + } +}); + +var Component = KeyringPrompter; diff --git a/js/ui/components/networkAgent.js b/js/ui/components/networkAgent.js new file mode 100644 index 0000000..ba02f88 --- /dev/null +++ b/js/ui/components/networkAgent.js @@ -0,0 +1,877 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GLib, GObject, NM, Pango, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; + +Gio._promisify(Shell.NetworkAgent.prototype, 'init_async'); +Gio._promisify(Shell.NetworkAgent.prototype, 'search_vpn_plugin'); + +const VPN_UI_GROUP = 'VPN Plugin UI'; + +var NetworkSecretDialog = GObject.registerClass( +class NetworkSecretDialog extends ModalDialog.ModalDialog { + _init(agent, requestId, connection, settingName, hints, flags, contentOverride) { + super._init({ styleClass: 'prompt-dialog' }); + + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._settingName = settingName; + this._hints = hints; + + if (contentOverride) + this._content = contentOverride; + else + this._content = this._getContent(); + + let contentBox = new Dialog.MessageDialogContent({ + title: this._content.title, + description: this._content.message, + }); + + let initialFocusSet = false; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + let reactive = secret.key != null; + + let entryParams = { + style_class: 'prompt-dialog-password-entry', + hint_text: secret.label, + text: secret.value, + can_focus: reactive, + reactive, + x_align: Clutter.ActorAlign.CENTER, + }; + if (secret.password) + secret.entry = new St.PasswordEntry(entryParams); + else + secret.entry = new St.Entry(entryParams); + ShellEntry.addContextMenu(secret.entry); + contentBox.add_child(secret.entry); + + if (secret.validate) + secret.valid = secret.validate(secret); + else // no special validation, just ensure it's not empty + secret.valid = secret.value.length > 0; + + if (reactive) { + if (!initialFocusSet) { + this.setInitialKeyFocus(secret.entry); + initialFocusSet = true; + } + + secret.entry.clutter_text.connect('activate', this._onOk.bind(this)); + secret.entry.clutter_text.connect('text-changed', () => { + secret.value = secret.entry.get_text(); + if (secret.validate) + secret.valid = secret.validate(secret); + else + secret.valid = secret.value.length > 0; + this._updateOkButton(); + }); + } else { + secret.valid = true; + } + } + + if (this._content.secrets.some(s => s.password)) { + let capsLockWarning = new ShellEntry.CapsLockWarning(); + contentBox.add_child(capsLockWarning); + } + + if (flags & NM.SecretAgentGetSecretsFlags.WPS_PBC_ACTIVE) { + let descriptionLabel = new St.Label({ + text: _('Alternatively you can connect by pushing the “WPS” button on your router.'), + style_class: 'message-dialog-description', + }); + descriptionLabel.clutter_text.line_wrap = true; + descriptionLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + contentBox.add_child(descriptionLabel); + } + + this.contentLayout.add_child(contentBox); + + this._okButton = { + label: _("Connect"), + action: this._onOk.bind(this), + default: true, + }; + + this.setButtons([{ + label: _("Cancel"), + action: this.cancel.bind(this), + key: Clutter.KEY_Escape, + }, this._okButton]); + + this._updateOkButton(); + } + + _updateOkButton() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid &&= secret.valid; + } + + this._okButton.button.reactive = valid; + this._okButton.button.can_focus = valid; + } + + _onOk() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid &&= secret.valid; + if (secret.key !== null) { + if (this._settingName === 'vpn') + this._agent.add_vpn_secret(this._requestId, secret.key, secret.value); + else + this._agent.set_password(this._requestId, secret.key, secret.value); + } + } + + if (valid) { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + this.close(global.get_current_time()); + } + // do nothing if not valid + } + + cancel() { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + this.close(global.get_current_time()); + } + + _validateWpaPsk(secret) { + let value = secret.value; + if (value.length == 64) { + // must be composed of hexadecimal digits only + for (let i = 0; i < 64; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F') || + (value[i] >= '0' && value[i] <= '9'))) + return false; + } + return true; + } + + return value.length >= 8 && value.length <= 63; + } + + _validateStaticWep(secret) { + let value = secret.value; + if (secret.wep_key_type == NM.WepKeyType.KEY) { + if (value.length == 10 || value.length == 26) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F') || + (value[i] >= '0' && value[i] <= '9'))) + return false; + } + } else if (value.length == 5 || value.length == 13) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'z') || + (value[i] >= 'A' && value[i] <= 'Z'))) + return false; + } + } else { + return false; + } + } else if (secret.wep_key_type == NM.WepKeyType.PASSPHRASE) { + if (value.length < 0 || value.length > 64) + return false; + } + return true; + } + + _getWirelessSecrets(secrets, _wirelessSetting) { + let wirelessSecuritySetting = this._connection.get_setting_wireless_security(); + + if (this._settingName == '802-1x') { + this._get8021xSecrets(secrets); + return; + } + + switch (wirelessSecuritySetting.key_mgmt) { + // First the easy ones + case 'wpa-none': + case 'wpa-psk': + case 'sae': + secrets.push({ + label: _('Password'), + key: 'psk', + value: wirelessSecuritySetting.psk || '', + validate: this._validateWpaPsk, + password: true, + }); + break; + case 'none': // static WEP + secrets.push({ + label: _('Key'), + key: `wep-key${wirelessSecuritySetting.wep_tx_keyidx}`, + value: wirelessSecuritySetting.get_wep_key(wirelessSecuritySetting.wep_tx_keyidx) || '', + wep_key_type: wirelessSecuritySetting.wep_key_type, + validate: this._validateStaticWep, + password: true, + }); + break; + case 'ieee8021x': + if (wirelessSecuritySetting.auth_alg == 'leap') { // Cisco LEAP + secrets.push({ + label: _('Password'), + key: 'leap-password', + value: wirelessSecuritySetting.leap_password || '', + password: true, + }); + } else { // Dynamic (IEEE 802.1x) WEP + this._get8021xSecrets(secrets); + } + break; + case 'wpa-eap': + this._get8021xSecrets(secrets); + break; + default: + log(`Invalid wireless key management: ${wirelessSecuritySetting.key_mgmt}`); + } + } + + _get8021xSecrets(secrets) { + let ieee8021xSetting = this._connection.get_setting_802_1x(); + + /* If hints were given we know exactly what we need to ask */ + if (this._settingName == "802-1x" && this._hints.length) { + if (this._hints.includes('identity')) { + secrets.push({ + label: _('Username'), + key: 'identity', + value: ieee8021xSetting.identity || '', + password: false, + }); + } + if (this._hints.includes('password')) { + secrets.push({ + label: _('Password'), + key: 'password', + value: ieee8021xSetting.password || '', + password: true, + }); + } + if (this._hints.includes('private-key-password')) { + secrets.push({ + label: _('Private key password'), + key: 'private-key-password', + value: ieee8021xSetting.private_key_password || '', + password: true, + }); + } + return; + } + + switch (ieee8021xSetting.get_eap_method(0)) { + case 'md5': + case 'leap': + case 'ttls': + case 'peap': + case 'fast': + // TTLS and PEAP are actually much more complicated, but this complication + // is not visible here since we only care about phase2 authentication + // (and don't even care of which one) + secrets.push({ + label: _('Username'), + key: null, + value: ieee8021xSetting.identity || '', + password: false, + }); + secrets.push({ + label: _('Password'), + key: 'password', + value: ieee8021xSetting.password || '', + password: true, + }); + break; + case 'tls': + secrets.push({ + label: _('Identity'), + key: null, + value: ieee8021xSetting.identity || '', + password: false, + }); + secrets.push({ + label: _('Private key password'), + key: 'private-key-password', + value: ieee8021xSetting.private_key_password || '', + password: true, + }); + break; + default: + log(`Invalid EAP/IEEE802.1x method: ${ieee8021xSetting.get_eap_method(0)}`); + } + } + + _getPPPoESecrets(secrets) { + let pppoeSetting = this._connection.get_setting_pppoe(); + secrets.push({ + label: _('Username'), + key: 'username', + value: pppoeSetting.username || '', + password: false, + }); + secrets.push({ + label: _('Service'), key: 'service', + value: pppoeSetting.service || '', + password: false, + }); + secrets.push({ + label: _('Password'), key: 'password', + value: pppoeSetting.password || '', + password: true, + }); + } + + _getMobileSecrets(secrets, connectionType) { + let setting; + if (connectionType == 'bluetooth') + setting = this._connection.get_setting_cdma() || this._connection.get_setting_gsm(); + else + setting = this._connection.get_setting_by_name(connectionType); + secrets.push({ + label: _('Password'), + key: 'password', + value: setting.value || '', + password: true, + }); + } + + _getContent() { + let connectionSetting = this._connection.get_setting_connection(); + let connectionType = connectionSetting.get_connection_type(); + let wirelessSetting; + let ssid; + + let content = { }; + content.secrets = []; + + switch (connectionType) { + case '802-11-wireless': + wirelessSetting = this._connection.get_setting_wireless(); + ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data()); + content.title = _('Authentication required'); + content.message = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid); + this._getWirelessSecrets(content.secrets, wirelessSetting); + break; + case '802-3-ethernet': + content.title = _("Wired 802.1X authentication"); + content.message = null; + content.secrets.push({ + label: _('Network name'), + key: null, + value: connectionSetting.get_id(), + password: false, + }); + this._get8021xSecrets(content.secrets); + break; + case 'pppoe': + content.title = _("DSL authentication"); + content.message = null; + this._getPPPoESecrets(content.secrets); + break; + case 'gsm': + if (this._hints.includes('pin')) { + let gsmSetting = this._connection.get_setting_gsm(); + content.title = _("PIN code required"); + content.message = _("PIN code is needed for the mobile broadband device"); + content.secrets.push({ + label: _('PIN'), + key: 'pin', + value: gsmSetting.pin || '', + password: true, + }); + break; + } + // fall through + case 'cdma': + case 'bluetooth': + content.title = _('Authentication required'); + content.message = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + this._getMobileSecrets(content.secrets, connectionType); + break; + default: + log(`Invalid connection type: ${connectionType}`); + } + + return content; + } +}); + +var VPNRequestHandler = class extends Signals.EventEmitter { + constructor(agent, requestId, authHelper, serviceType, connection, hints, flags) { + super(); + + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._flags = flags; + this._pluginOutBuffer = []; + this._title = null; + this._description = null; + this._content = []; + this._shellDialog = null; + + let connectionSetting = connection.get_setting_connection(); + + const argv = [ + authHelper.fileName, + '-u', connectionSetting.uuid, + '-n', connectionSetting.id, + '-s', serviceType, + ]; + if (authHelper.externalUIMode) + argv.push('--external-ui-mode'); + if (flags & NM.SecretAgentGetSecretsFlags.ALLOW_INTERACTION) + argv.push('-i'); + if (flags & NM.SecretAgentGetSecretsFlags.REQUEST_NEW) + argv.push('-r'); + if (authHelper.supportsHints) { + for (let i = 0; i < hints.length; i++) { + argv.push('-t'); + argv.push(hints[i]); + } + } + + this._newStylePlugin = authHelper.externalUIMode; + + try { + let [success_, pid, stdin, stdout, stderr] = + GLib.spawn_async_with_pipes( + null, /* pwd */ + argv, + null, /* envp */ + GLib.SpawnFlags.DO_NOT_REAP_CHILD, + () => { + try { + global.context.restore_rlimit_nofile(); + } catch (err) { + } + }); + + this._childPid = pid; + this._stdin = new Gio.UnixOutputStream({ fd: stdin, close_fd: true }); + this._stdout = new Gio.UnixInputStream({ fd: stdout, close_fd: true }); + GLib.close(stderr); + this._dataStdout = new Gio.DataInputStream({ base_stream: this._stdout }); + + if (this._newStylePlugin) + this._readStdoutNewStyle(); + else + this._readStdoutOldStyle(); + + this._childWatch = GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, + this._vpnChildFinished.bind(this)); + + this._writeConnection(); + } catch (e) { + logError(e, 'error while spawning VPN auth helper'); + + this._agent.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + } + + cancel(respond) { + if (respond) + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + + if (this._newStylePlugin && this._shellDialog) { + this._shellDialog.close(global.get_current_time()); + this._shellDialog.destroy(); + } else { + try { + this._stdin.write('QUIT\n\n', null); + } catch (e) { /* ignore broken pipe errors */ } + } + + this.destroy(); + } + + destroy() { + if (this._destroyed) + return; + + this.emit('destroy'); + if (this._childWatch) + GLib.source_remove(this._childWatch); + + this._stdin.close(null); + // Stdout is closed when we finish reading from it + + this._destroyed = true; + } + + _vpnChildFinished(pid, status, _requestObj) { + this._childWatch = 0; + if (this._newStylePlugin) { + // For new style plugin, all work is done in the async reading functions + // Just reap the process here + return; + } + + let [exited, exitStatus] = Shell.util_wifexited(status); + + if (exited) { + if (exitStatus != 0) + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + else + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + } else { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + + this.destroy(); + } + + _vpnChildProcessLineOldStyle(line) { + if (this._previousLine != undefined) { + // Two consecutive newlines mean that the child should be closed + // (the actual newlines are eaten by Gio.DataInputStream) + // Send a termination message + if (line == '' && this._previousLine == '') { + try { + this._stdin.write('QUIT\n\n', null); + } catch (e) { /* ignore broken pipe errors */ } + } else { + this._agent.add_vpn_secret(this._requestId, this._previousLine, line); + this._previousLine = undefined; + } + } else { + this._previousLine = line; + } + } + + async _readStdoutOldStyle() { + const [line, len_] = + await this._dataStdout.read_line_async(GLib.PRIORITY_DEFAULT, null); + + if (line === null) { + // end of file + this._stdout.close(null); + return; + } + + const decoder = new TextDecoder(); + this._vpnChildProcessLineOldStyle(decoder.decode(line)); + + // try to read more! + this._readStdoutOldStyle(); + } + + async _readStdoutNewStyle() { + const cnt = + await this._dataStdout.fill_async(-1, GLib.PRIORITY_DEFAULT, null); + + if (cnt === 0) { + // end of file + this._showNewStyleDialog(); + + this._stdout.close(null); + return; + } + + // Try to read more + this._dataStdout.set_buffer_size(2 * this._dataStdout.get_buffer_size()); + this._readStdoutNewStyle(); + } + + _showNewStyleDialog() { + let keyfile = new GLib.KeyFile(); + let data; + let contentOverride; + + try { + data = new GLib.Bytes(this._dataStdout.peek_buffer()); + keyfile.load_from_bytes(data, GLib.KeyFileFlags.NONE); + + if (keyfile.get_integer(VPN_UI_GROUP, 'Version') != 2) + throw new Error('Invalid plugin keyfile version, is %d'); + + contentOverride = { + title: keyfile.get_string(VPN_UI_GROUP, 'Title'), + message: keyfile.get_string(VPN_UI_GROUP, 'Description'), + secrets: [], + }; + + let [groups, len_] = keyfile.get_groups(); + for (let i = 0; i < groups.length; i++) { + if (groups[i] == VPN_UI_GROUP) + continue; + + let value = keyfile.get_string(groups[i], 'Value'); + let shouldAsk = keyfile.get_boolean(groups[i], 'ShouldAsk'); + + if (shouldAsk) { + contentOverride.secrets.push({ + label: keyfile.get_string(groups[i], 'Label'), + key: groups[i], + value, + password: keyfile.get_boolean(groups[i], 'IsSecret'), + }); + } else { + if (!value.length) // Ignore empty secrets + continue; + + this._agent.add_vpn_secret(this._requestId, groups[i], value); + } + } + } catch (e) { + // No output is a valid case it means "both secrets are stored" + if (data.length > 0) { + logError(e, 'error while reading VPN plugin output keyfile'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + this.destroy(); + return; + } + } + + if (contentOverride && contentOverride.secrets.length) { + // Only show the dialog if we actually have something to ask + this._shellDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], this._flags, contentOverride); + this._shellDialog.open(global.get_current_time()); + } else { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + this.destroy(); + } + } + + _writeConnection() { + let vpnSetting = this._connection.get_setting_vpn(); + + try { + vpnSetting.foreach_data_item((key, value) => { + this._stdin.write(`DATA_KEY=${key}\n`, null); + this._stdin.write(`DATA_VAL=${value || ''}\n\n`, null); + }); + vpnSetting.foreach_secret((key, value) => { + this._stdin.write(`SECRET_KEY=${key}\n`, null); + this._stdin.write(`SECRET_VAL=${value || ''}\n\n`, null); + }); + this._stdin.write('DONE\n\n', null); + } catch (e) { + logError(e, 'internal error while writing connection to helper'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + this.destroy(); + } + } +}; + +var NetworkAgent = class { + constructor() { + this._native = new Shell.NetworkAgent({ + identifier: 'org.gnome.Shell.NetworkAgent', + capabilities: NM.SecretAgentCapabilities.VPN_HINTS, + auto_register: false, + }); + + this._dialogs = { }; + this._vpnRequests = { }; + this._notifications = { }; + + this._native.connect('new-request', this._newRequest.bind(this)); + this._native.connect('cancel-request', this._cancelRequest.bind(this)); + + this._initialized = false; + this._initNative(); + } + + async _initNative() { + try { + await this._native.init_async(GLib.PRIORITY_DEFAULT, null); + this._initialized = true; + } catch (e) { + this._native = null; + logError(e, 'error initializing the NetworkManager Agent'); + } + } + + enable() { + if (!this._native) + return; + + this._native.auto_register = true; + if (this._initialized && !this._native.registered) + this._native.register_async(null, null); + } + + disable() { + let requestId; + + for (requestId in this._dialogs) + this._dialogs[requestId].cancel(); + this._dialogs = { }; + + for (requestId in this._vpnRequests) + this._vpnRequests[requestId].cancel(true); + this._vpnRequests = { }; + + for (requestId in this._notifications) + this._notifications[requestId].destroy(); + this._notifications = { }; + + if (!this._native) + return; + + this._native.auto_register = false; + if (this._initialized && this._native.registered) + this._native.unregister_async(null, null); + } + + _showNotification(requestId, connection, settingName, hints, flags) { + let source = new MessageTray.Source(_("Network Manager"), 'network-transmit-receive'); + source.policy = new MessageTray.NotificationApplicationPolicy('gnome-network-panel'); + + let title, body; + + let connectionSetting = connection.get_setting_connection(); + let connectionType = connectionSetting.get_connection_type(); + switch (connectionType) { + case '802-11-wireless': { + let wirelessSetting = connection.get_setting_wireless(); + let ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data()); + title = _('Authentication required'); + body = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid); + break; + } + case '802-3-ethernet': + title = _("Wired 802.1X authentication"); + body = _('A password is required to connect to “%s”.').format(connection.get_id()); + break; + case 'pppoe': + title = _("DSL authentication"); + body = _('A password is required to connect to “%s”.').format(connection.get_id()); + break; + case 'gsm': + if (hints.includes('pin')) { + title = _("PIN code required"); + body = _("PIN code is needed for the mobile broadband device"); + break; + } + // fall through + case 'cdma': + case 'bluetooth': + title = _('Authentication required'); + body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + break; + case 'vpn': + title = _("VPN password"); + body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + break; + default: + log(`Invalid connection type: ${connectionType}`); + this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + let notification = new MessageTray.Notification(source, title, body); + + notification.connect('activated', () => { + notification.answered = true; + this._handleRequest(requestId, connection, settingName, hints, flags); + }); + + this._notifications[requestId] = notification; + notification.connect('destroy', () => { + if (!notification.answered) + this._native.respond(requestId, Shell.NetworkAgentResponse.USER_CANCELED); + delete this._notifications[requestId]; + }); + + Main.messageTray.add(source); + source.showNotification(notification); + } + + _newRequest(agent, requestId, connection, settingName, hints, flags) { + if (!(flags & NM.SecretAgentGetSecretsFlags.USER_REQUESTED)) + this._showNotification(requestId, connection, settingName, hints, flags); + else + this._handleRequest(requestId, connection, settingName, hints, flags); + } + + _handleRequest(requestId, connection, settingName, hints, flags) { + if (settingName == 'vpn') { + this._vpnRequest(requestId, connection, hints, flags); + return; + } + + let dialog = new NetworkSecretDialog(this._native, requestId, connection, settingName, hints, flags); + dialog.connect('destroy', () => { + delete this._dialogs[requestId]; + }); + this._dialogs[requestId] = dialog; + dialog.open(global.get_current_time()); + } + + _cancelRequest(agent, requestId) { + if (this._dialogs[requestId]) { + this._dialogs[requestId].close(global.get_current_time()); + this._dialogs[requestId].destroy(); + delete this._dialogs[requestId]; + } else if (this._vpnRequests[requestId]) { + this._vpnRequests[requestId].cancel(false); + delete this._vpnRequests[requestId]; + } + } + + async _vpnRequest(requestId, connection, hints, flags) { + let vpnSetting = connection.get_setting_vpn(); + let serviceType = vpnSetting.service_type; + + let binary = await this._findAuthBinary(serviceType); + if (!binary) { + log('Invalid VPN service type (cannot find authentication binary)'); + + /* cancel the auth process */ + this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + let vpnRequest = new VPNRequestHandler(this._native, requestId, binary, serviceType, connection, hints, flags); + vpnRequest.connect('destroy', () => { + delete this._vpnRequests[requestId]; + }); + this._vpnRequests[requestId] = vpnRequest; + } + + async _findAuthBinary(serviceType) { + let plugin; + + try { + plugin = await this._native.search_vpn_plugin(serviceType); + } catch (e) { + logError(e); + return null; + } + + const fileName = plugin.get_auth_dialog(); + if (!GLib.file_test(fileName, GLib.FileTest.IS_EXECUTABLE)) { + log(`VPN plugin at ${fileName} is not executable`); + return null; + } + + const prop = plugin.lookup_property('GNOME', 'supports-external-ui-mode'); + const trimmedProp = prop?.trim().toLowerCase() ?? ''; + + return { + fileName, + supportsHints: plugin.supports_hints(), + externalUIMode: ['true', 'yes', 'on', '1'].includes(trimmedProp), + }; + } +}; +var Component = NetworkAgent; diff --git a/js/ui/components/polkitAgent.js b/js/ui/components/polkitAgent.js new file mode 100644 index 0000000..1da02e5 --- /dev/null +++ b/js/ui/components/polkitAgent.js @@ -0,0 +1,471 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { + AccountsService, Clutter, GLib, GObject, + Pango, PolkitAgent, Polkit, Shell, St, +} = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const UserWidget = imports.ui.userWidget; +const Util = imports.misc.util; + +const DialogMode = { + AUTH: 0, + CONFIRM: 1, +}; + +const DIALOG_ICON_SIZE = 64; + +const DELAYED_RESET_TIMEOUT = 200; + +var AuthenticationDialog = GObject.registerClass({ + Signals: { 'done': { param_types: [GObject.TYPE_BOOLEAN] } }, +}, class AuthenticationDialog extends ModalDialog.ModalDialog { + _init(actionId, description, cookie, userNames) { + super._init({ styleClass: 'prompt-dialog' }); + + this.actionId = actionId; + this.message = description; + this.userNames = userNames; + + Main.sessionMode.connectObject('updated', () => { + this.visible = !Main.sessionMode.isLocked; + }, this); + + this.connect('closed', this._onDialogClosed.bind(this)); + + let title = _("Authentication Required"); + + let headerContent = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_child(headerContent); + + let bodyContent = new Dialog.MessageDialogContent(); + + if (userNames.length > 1) { + log(`polkitAuthenticationAgent: Received ${userNames.length} ` + + 'identities that can be used for authentication. Only ' + + 'considering one.'); + } + + let userName = GLib.get_user_name(); + if (!userNames.includes(userName)) + userName = 'root'; + if (!userNames.includes(userName)) + userName = userNames[0]; + + this._user = AccountsService.UserManager.get_default().get_user(userName); + + let userBox = new St.BoxLayout({ + style_class: 'polkit-dialog-user-layout', + vertical: true, + }); + bodyContent.add_child(userBox); + + this._userAvatar = new UserWidget.Avatar(this._user, { + iconSize: DIALOG_ICON_SIZE, + }); + this._userAvatar.x_align = Clutter.ActorAlign.CENTER; + userBox.add_child(this._userAvatar); + + this._userLabel = new St.Label({ + style_class: userName === 'root' + ? 'polkit-dialog-user-root-label' + : 'polkit-dialog-user-label', + }); + + if (userName === 'root') + this._userLabel.text = _('Administrator'); + + userBox.add_child(this._userLabel); + + let passwordBox = new St.BoxLayout({ + style_class: 'prompt-dialog-password-layout', + vertical: true, + }); + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + text: "", + can_focus: true, + visible: false, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._passwordEntry); + this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + this._passwordEntry.bind_property('reactive', + this._passwordEntry.clutter_text, 'editable', + GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._passwordEntry); + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + this._passwordEntry.bind_property('visible', + capsLockWarning, 'visible', + GObject.BindingFlags.SYNC_CREATE); + warningBox.add_child(capsLockWarning); + + this._errorMessageLabel = new St.Label({ + style_class: 'prompt-dialog-error-label', + visible: false, + }); + this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._errorMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._errorMessageLabel); + + this._infoMessageLabel = new St.Label({ + style_class: 'prompt-dialog-info-label', + visible: false, + }); + this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._infoMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._infoMessageLabel); + + /* text is intentionally non-blank otherwise the height is not the same as for + * infoMessage and errorMessageLabel - but it is still invisible because + * gnome-shell.css sets the color to be transparent + */ + this._nullMessageLabel = new St.Label({ style_class: 'prompt-dialog-null-label' }); + this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._nullMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._nullMessageLabel); + + passwordBox.add_child(warningBox); + bodyContent.add_child(passwordBox); + + this._cancelButton = this.addButton({ + label: _('Cancel'), + action: this.cancel.bind(this), + key: Clutter.KEY_Escape, + }); + this._okButton = this.addButton({ + label: _('Authenticate'), + action: this._onAuthenticateButtonPressed.bind(this), + reactive: false, + }); + this._okButton.bind_property('reactive', + this._okButton, 'can-focus', + GObject.BindingFlags.SYNC_CREATE); + + this._passwordEntry.clutter_text.connect('text-changed', text => { + this._okButton.reactive = text.get_text().length > 0; + }); + + this.contentLayout.add_child(bodyContent); + + this._doneEmitted = false; + + this._mode = -1; + + this._identityToAuth = Polkit.UnixUser.new_for_name(userName); + this._cookie = cookie; + + this._user.connectObject( + 'notify::is-loaded', this._onUserChanged.bind(this), + 'changed', this._onUserChanged.bind(this), this); + this._onUserChanged(); + } + + _initiateSession() { + this._destroySession(DELAYED_RESET_TIMEOUT); + + this._session = new PolkitAgent.Session({ + identity: this._identityToAuth, + cookie: this._cookie, + }); + this._session.connectObject( + 'completed', this._onSessionCompleted.bind(this), + 'request', this._onSessionRequest.bind(this), + 'show-error', this._onSessionShowError.bind(this), + 'show-info', this._onSessionShowInfo.bind(this), this); + this._session.initiate(); + } + + _ensureOpen() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (!this.open(global.get_current_time())) { + // This can fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + // + // One way to make this happen is by running 'sleep 3; + // pkexec bash' and then opening a popup menu. + // + // We could add retrying if this turns out to be a problem + + log('polkitAuthenticationAgent: Failed to show modal dialog. ' + + `Dismissing authentication request for action-id ${this.actionId} ` + + `cookie ${this._cookie}`); + this._emitDone(true); + } + } + + _emitDone(dismissed) { + if (!this._doneEmitted) { + this._doneEmitted = true; + this.emit('done', dismissed); + } + } + + _onEntryActivate() { + let response = this._passwordEntry.get_text(); + if (response.length === 0) + return; + + this._passwordEntry.reactive = false; + this._okButton.reactive = false; + + this._session.response(response); + // When the user responds, dismiss already shown info and + // error texts (if any) + this._errorMessageLabel.hide(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.show(); + } + + _onAuthenticateButtonPressed() { + if (this._mode === DialogMode.CONFIRM) + this._initiateSession(); + else + this._onEntryActivate(); + } + + _onSessionCompleted(session, gainedAuthorization) { + if (this._completed || this._doneEmitted) + return; + + this._completed = true; + + /* Yay, all done */ + if (gainedAuthorization) { + this._emitDone(false); + } else { + /* Unless we are showing an existing error message from the PAM + * module (the PAM module could be reporting the authentication + * error providing authentication-method specific information), + * show "Sorry, that didn't work. Please try again." + */ + if (!this._errorMessageLabel.visible) { + /* Translators: "that didn't work" refers to the fact that the + * requested authentication was not gained; this can happen + * because of an authentication error (like invalid password), + * for instance. */ + this._errorMessageLabel.set_text(_("Sorry, that didn’t work. Please try again.")); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + + Util.wiggle(this._passwordEntry); + } + + /* Try and authenticate again */ + this._initiateSession(); + } + } + + _onSessionRequest(session, request, echoOn) { + if (this._sessionRequestTimeoutId) { + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + } + + // Hack: The request string comes directly from PAM, if it's "Password:" + // we replace it with our own to allow localization, if it's something + // else we remove the last colon and any trailing or leading spaces. + if (request === 'Password:' || request === 'Password: ') + this._passwordEntry.hint_text = _('Password'); + else + this._passwordEntry.hint_text = request.replace(/: *$/, '').trim(); + + this._passwordEntry.password_visible = echoOn; + + this._passwordEntry.show(); + this._passwordEntry.set_text(''); + this._passwordEntry.reactive = true; + this._okButton.reactive = false; + + this._ensureOpen(); + this._passwordEntry.grab_key_focus(); + } + + _onSessionShowError(session, text) { + this._passwordEntry.set_text(''); + this._errorMessageLabel.set_text(text); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + } + + _onSessionShowInfo(session, text) { + this._passwordEntry.set_text(''); + this._infoMessageLabel.set_text(text); + this._infoMessageLabel.show(); + this._errorMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + } + + _destroySession(delay = 0) { + this._session?.disconnectObject(this); + + if (!this._completed) + this._session?.cancel(); + + this._completed = false; + this._session = null; + + if (this._sessionRequestTimeoutId) { + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + } + + let resetDialog = () => { + this._sessionRequestTimeoutId = 0; + + if (this.state != ModalDialog.State.OPENED) + return GLib.SOURCE_REMOVE; + + this._passwordEntry.hide(); + this._cancelButton.grab_key_focus(); + this._okButton.reactive = false; + + return GLib.SOURCE_REMOVE; + }; + + if (delay) { + this._sessionRequestTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, resetDialog); + GLib.Source.set_name_by_id(this._sessionRequestTimeoutId, '[gnome-shell] this._sessionRequestTimeoutId'); + } else { + resetDialog(); + } + } + + _onUserChanged() { + if (!this._user.is_loaded) + return; + + let userName = this._user.get_user_name(); + let realName = this._user.get_real_name(); + + if (userName !== 'root') + this._userLabel.set_text(realName); + + this._userAvatar.update(); + + if (this._user.get_password_mode() === AccountsService.UserPasswordMode.NONE) { + if (this._mode === DialogMode.CONFIRM) + return; + + this._mode = DialogMode.CONFIRM; + this._destroySession(); + + this._okButton.reactive = true; + + /* We normally open the dialog when we get a "request" signal, but + * since in this case initiating a session would perform the + * authentication, only open the dialog and initiate the session + * when the user confirmed. */ + this._ensureOpen(); + } else { + if (this._mode === DialogMode.AUTH) + return; + + this._mode = DialogMode.AUTH; + this._initiateSession(); + } + } + + close(timestamp) { + // Ensure cleanup if the dialog was never shown + if (this.state === ModalDialog.State.CLOSED) + this._onDialogClosed(); + super.close(timestamp); + } + + cancel() { + this._emitDone(true); + } + + _onDialogClosed() { + Main.sessionMode.disconnectObject(this); + + if (this._sessionRequestTimeoutId) + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + + this._user?.disconnectObject(this); + this._user = null; + + this._destroySession(); + } +}); + +var AuthenticationAgent = GObject.registerClass( +class AuthenticationAgent extends Shell.PolkitAuthenticationAgent { + _init() { + super._init(); + + this._currentDialog = null; + this.connect('initiate', this._onInitiate.bind(this)); + this.connect('cancel', this._onCancel.bind(this)); + this._sessionUpdatedId = 0; + } + + enable() { + try { + this.register(); + } catch (e) { + log('Failed to register AuthenticationAgent'); + } + } + + disable() { + try { + this.unregister(); + } catch (e) { + log('Failed to unregister AuthenticationAgent'); + } + } + + _onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames) { + // Don't pop up a dialog while locked + if (Main.sessionMode.isLocked) { + Main.sessionMode.connectObject('updated', () => { + Main.sessionMode.disconnectObject(this); + + this._onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames); + }, this); + return; + } + + this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames); + this._currentDialog.connect('done', this._onDialogDone.bind(this)); + } + + _onCancel(_nativeAgent) { + this._completeRequest(false); + } + + _onDialogDone(_dialog, dismissed) { + this._completeRequest(dismissed); + } + + _completeRequest(dismissed) { + this._currentDialog.close(); + this._currentDialog = null; + + Main.sessionMode.disconnectObject(this); + + this.complete(dismissed); + } +}); + +var Component = AuthenticationAgent; diff --git a/js/ui/components/telepathyClient.js b/js/ui/components/telepathyClient.js new file mode 100644 index 0000000..d317822 --- /dev/null +++ b/js/ui/components/telepathyClient.js @@ -0,0 +1,1019 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GLib, GObject, St } = imports.gi; + +var Tpl = null; +var Tp = null; +try { + ({ TelepathyGLib: Tp, TelepathyLogger: Tpl } = imports.gi); + + Gio._promisify(Tp.Channel.prototype, 'close_async'); + Gio._promisify(Tp.TextChannel.prototype, 'send_message_async'); + Gio._promisify(Tp.ChannelDispatchOperation.prototype, 'claim_with_async'); + Gio._promisify(Tpl.LogManager.prototype, 'get_filtered_events_async'); +} catch (e) { + log('Telepathy is not available, chat integration will be disabled.'); +} + +const History = imports.misc.history; +const Main = imports.ui.main; +const MessageList = imports.ui.messageList; +const MessageTray = imports.ui.messageTray; +const Params = imports.misc.params; +const Util = imports.misc.util; + +const HAVE_TP = Tp != null && Tpl != null; + +// See Notification.appendMessage +var SCROLLBACK_IMMEDIATE_TIME = 3 * 60; // 3 minutes +var SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes +var SCROLLBACK_RECENT_LENGTH = 20; +var SCROLLBACK_IDLE_LENGTH = 5; + +// See Source._displayPendingMessages +var SCROLLBACK_HISTORY_LINES = 10; + +// See Notification._onEntryChanged +var COMPOSING_STOP_TIMEOUT = 5; + +var CHAT_EXPAND_LINES = 12; + +var NotificationDirection = { + SENT: 'chat-sent', + RECEIVED: 'chat-received', +}; + +const ChatMessage = HAVE_TP ? GObject.registerClass({ + Properties: { + 'message-type': GObject.ParamSpec.int( + 'message-type', 'message-type', 'message-type', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + Math.min(...Object.values(Tp.ChannelTextMessageType)), + Math.max(...Object.values(Tp.ChannelTextMessageType)), + Tp.ChannelTextMessageType.NORMAL), + 'text': GObject.ParamSpec.string( + 'text', 'text', 'text', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'sender': GObject.ParamSpec.string( + 'sender', 'sender', 'sender', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'timestamp': GObject.ParamSpec.int64( + 'timestamp', 'timestamp', 'timestamp', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + 0, Number.MAX_SAFE_INTEGER, 0), + 'direction': GObject.ParamSpec.string( + 'direction', 'direction', 'direction', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + }, +}, class ChatMessageClass extends GObject.Object { + static newFromTpMessage(tpMessage, direction) { + return new ChatMessage({ + 'message-type': tpMessage.get_message_type(), + 'text': tpMessage.to_text()[0], + 'sender': tpMessage.sender.alias, + 'timestamp': direction === NotificationDirection.RECEIVED + ? tpMessage.get_received_timestamp() : tpMessage.get_sent_timestamp(), + direction, + }); + } + + static newFromTplTextEvent(tplTextEvent) { + let direction = + tplTextEvent.get_sender().get_entity_type() === Tpl.EntityType.SELF + ? NotificationDirection.SENT : NotificationDirection.RECEIVED; + + return new ChatMessage({ + 'message-type': tplTextEvent.get_message_type(), + 'text': tplTextEvent.get_message(), + 'sender': tplTextEvent.get_sender().get_alias(), + 'timestamp': tplTextEvent.get_timestamp(), + direction, + }); + } +}) : null; + + +var TelepathyComponent = class { + constructor() { + this._client = null; + + if (!HAVE_TP) + return; // Telepathy isn't available + + this._client = new TelepathyClient(); + } + + enable() { + if (!this._client) + return; + + try { + this._client.register(); + } catch (e) { + throw new Error(`Could not register Telepathy client. Error: ${e}`); + } + + if (!this._client.account_manager.is_prepared(Tp.AccountManager.get_feature_quark_core())) + this._client.account_manager.prepare_async(null, null); + } + + disable() { + if (!this._client) + return; + + this._client.unregister(); + } +}; + +var TelepathyClient = HAVE_TP ? GObject.registerClass( +class TelepathyClient extends Tp.BaseClient { + _init() { + // channel path -> ChatSource + this._chatSources = {}; + this._chatState = Tp.ChannelChatState.ACTIVE; + + // account path -> AccountNotification + this._accountNotifications = {}; + + // Define features we want + this._accountManager = Tp.AccountManager.dup(); + let factory = this._accountManager.get_factory(); + factory.add_account_features([Tp.Account.get_feature_quark_connection()]); + factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]); + factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]); + factory.add_contact_features([ + Tp.ContactFeature.ALIAS, + Tp.ContactFeature.AVATAR_DATA, + Tp.ContactFeature.PRESENCE, + Tp.ContactFeature.SUBSCRIPTION_STATES, + ]); + + // Set up a SimpleObserver, which will call _observeChannels whenever a + // channel matching its filters is detected. + // The second argument, recover, means _observeChannels will be run + // for any existing channel as well. + super._init({ + name: 'GnomeShell', + account_manager: this._accountManager, + uniquify_name: true, + }); + + // We only care about single-user text-based chats + let filter = {}; + filter[Tp.PROP_CHANNEL_CHANNEL_TYPE] = Tp.IFACE_CHANNEL_TYPE_TEXT; + filter[Tp.PROP_CHANNEL_TARGET_HANDLE_TYPE] = Tp.HandleType.CONTACT; + + this.set_observer_recover(true); + this.add_observer_filter(filter); + this.add_approver_filter(filter); + this.add_handler_filter(filter); + + // Allow other clients (such as Empathy) to preempt our channels if + // needed + this.set_delegated_channels_callback( + this._delegatedChannelsCb.bind(this)); + } + + vfunc_observe_channels(...args) { + let [account, conn, channels, dispatchOp_, requests_, context] = args; + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + let [targetHandle_, targetHandleType] = channel.get_handle(); + + if (channel.get_invalidated()) + continue; + + /* Only observe contact text channels */ + if (!(channel instanceof Tp.TextChannel) || + targetHandleType != Tp.HandleType.CONTACT) + continue; + + this._createChatSource(account, conn, channel, channel.get_target_contact()); + } + + context.accept(); + } + + _createChatSource(account, conn, channel, contact) { + if (this._chatSources[channel.get_object_path()]) + return; + + let source = new ChatSource(account, conn, channel, contact, this); + + this._chatSources[channel.get_object_path()] = source; + source.connect('destroy', () => { + delete this._chatSources[channel.get_object_path()]; + }); + } + + vfunc_handle_channels(...args) { + let [account, conn, channels, requests_, userActionTime_, context] = args; + this._handlingChannels(account, conn, channels, true); + context.accept(); + } + + _handlingChannels(account, conn, channels, notify) { + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + + // We can only handle text channel, so close any other channel + if (!(channel instanceof Tp.TextChannel)) { + channel.close_async(); + continue; + } + + if (channel.get_invalidated()) + continue; + + // 'notify' will be true when coming from an actual HandleChannels + // call, and not when from a successful Claim call. The point is + // we don't want to notify for a channel we just claimed which + // has no new messages (for example, a new channel which only has + // a delivery notification). We rely on _displayPendingMessages() + // and _messageReceived() to notify for new messages. + + // But we should still notify from HandleChannels because the + // Telepathy spec states that handlers must foreground channels + // in HandleChannels calls which are already being handled. + + if (notify && this.is_handling_channel(channel)) { + // We are already handling the channel, display the source + let source = this._chatSources[channel.get_object_path()]; + if (source) + source.showNotification(); + } + } + } + + vfunc_add_dispatch_operation(...args) { + let [account, conn, channels, dispatchOp, context] = args; + let channel = channels[0]; + let chanType = channel.get_channel_type(); + + if (channel.get_invalidated()) { + context.fail(new Tp.Error({ + code: Tp.Error.INVALID_ARGUMENT, + message: 'Channel is invalidated', + })); + return; + } + + if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT) { + this._approveTextChannel(account, conn, channel, dispatchOp, context); + } else { + context.fail(new Tp.Error({ + code: Tp.Error.INVALID_ARGUMENT, + message: 'Unsupported channel type', + })); + } + } + + async _approveTextChannel(account, conn, channel, dispatchOp, context) { + let [targetHandle_, targetHandleType] = channel.get_handle(); + + if (targetHandleType != Tp.HandleType.CONTACT) { + context.fail(new Tp.Error({ + code: Tp.Error.INVALID_ARGUMENT, + message: 'Unsupported handle type', + })); + return; + } + + context.accept(); + + // Approve private text channels right away as we are going to handle it + try { + await dispatchOp.claim_with_async(this); + this._handlingChannels(account, conn, [channel], false); + } catch (err) { + log(`Failed to claim channel: ${err}`); + } + } + + _delegatedChannelsCb(_client, _channels) { + // Nothing to do as we don't make a distinction between observed and + // handled channels. + } +}) : null; + +var ChatSource = HAVE_TP ? GObject.registerClass( +class ChatSource extends MessageTray.Source { + _init(account, conn, channel, contact, client) { + this._account = account; + this._contact = contact; + this._client = client; + + super._init(contact.get_alias()); + + this.isChat = true; + this._pendingMessages = []; + + this._conn = conn; + this._channel = channel; + + this._notifyTimeoutId = 0; + + this._presence = contact.get_presence_type(); + + this._channel.connectObject( + 'invalidated', this._channelClosed.bind(this), + 'message-sent', this._messageSent.bind(this), + 'message-received', this._messageReceived.bind(this), + 'pending-message-removed', this._pendingRemoved.bind(this), this); + + this._contact.connectObject( + 'notify::alias', this._updateAlias.bind(this), + 'notify::avatar-file', this._updateAvatarIcon.bind(this), + 'presence-changed', this._presenceChanged.bind(this), this); + + // Add ourselves as a source. + Main.messageTray.add(this); + + this._getLogMessages(); + } + + _ensureNotification() { + if (this._notification) + return; + + this._notification = new ChatNotification(this); + this._notification.connectObject( + 'activated', this.open.bind(this), + 'destroy', () => (this._notification = null), + 'updated', () => { + if (this._banner && this._banner.expanded) + this._ackMessages(); + }, this); + this.pushNotification(this._notification); + } + + _createPolicy() { + if (this._account.protocol_name == 'irc') + return new MessageTray.NotificationApplicationPolicy('org.gnome.Polari'); + return new MessageTray.NotificationApplicationPolicy('empathy'); + } + + createBanner() { + this._banner = new ChatNotificationBanner(this._notification); + + // We ack messages when the user expands the new notification + this._banner.connectObject( + 'expanded', this._ackMessages.bind(this), + 'destroy', () => (this._banner = null), this); + + return this._banner; + } + + _updateAlias() { + let oldAlias = this.title; + let newAlias = this._contact.get_alias(); + + if (oldAlias == newAlias) + return; + + this.setTitle(newAlias); + if (this._notification) + this._notification.appendAliasChange(oldAlias, newAlias); + } + + getIcon() { + let file = this._contact.get_avatar_file(); + if (file) + return new Gio.FileIcon({ file }); + else + return new Gio.ThemedIcon({ name: 'avatar-default' }); + } + + getSecondaryIcon() { + let iconName; + let presenceType = this._contact.get_presence_type(); + + switch (presenceType) { + case Tp.ConnectionPresenceType.AVAILABLE: + iconName = 'user-available'; + break; + case Tp.ConnectionPresenceType.BUSY: + iconName = 'user-busy'; + break; + case Tp.ConnectionPresenceType.OFFLINE: + iconName = 'user-offline'; + break; + case Tp.ConnectionPresenceType.HIDDEN: + iconName = 'user-invisible'; + break; + case Tp.ConnectionPresenceType.AWAY: + iconName = 'user-away'; + break; + case Tp.ConnectionPresenceType.EXTENDED_AWAY: + iconName = 'user-idle'; + break; + default: + iconName = 'user-offline'; + } + return new Gio.ThemedIcon({ name: iconName }); + } + + _updateAvatarIcon() { + this.iconUpdated(); + if (this._notification) { + this._notification.update(this._notification.title, + this._notification.bannerBodyText, + { gicon: this.getIcon() }); + } + } + + open() { + Main.overview.hide(); + Main.panel.closeCalendar(); + + if (this._client.is_handling_channel(this._channel)) { + // We are handling the channel, try to pass it to Empathy or Polari + // (depending on the channel type) + // We don't check if either app is available - mission control will + // fallback to something else if activation fails + + let target; + if (this._channel.connection.protocol_name == 'irc') + target = 'org.freedesktop.Telepathy.Client.Polari'; + else + target = 'org.freedesktop.Telepathy.Client.Empathy.Chat'; + this._client.delegate_channels_async([this._channel], global.get_current_time(), target, null); + } else { + // We are not the handler, just ask to present the channel + let dbus = Tp.DBusDaemon.dup(); + let cd = Tp.ChannelDispatcher.new(dbus); + + cd.present_channel_async(this._channel, global.get_current_time(), null); + } + } + + async _getLogMessages() { + let logManager = Tpl.LogManager.dup_singleton(); + let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT); + + const [events] = await logManager.get_filtered_events_async( + this._account, entity, + Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES, + null); + + let logMessages = events.map(e => ChatMessage.newFromTplTextEvent(e)); + this._ensureNotification(); + + let pendingTpMessages = this._channel.get_pending_messages(); + let pendingMessages = []; + + for (let i = 0; i < pendingTpMessages.length; i++) { + let message = pendingTpMessages[i]; + + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + continue; + + pendingMessages.push(ChatMessage.newFromTpMessage(message, + NotificationDirection.RECEIVED)); + + this._pendingMessages.push(message); + } + + this.countUpdated(); + + let showTimestamp = false; + + for (let i = 0; i < logMessages.length; i++) { + let logMessage = logMessages[i]; + let isPending = false; + + // Skip any log messages that are also in pendingMessages + for (let j = 0; j < pendingMessages.length; j++) { + let pending = pendingMessages[j]; + if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) { + isPending = true; + break; + } + } + + if (!isPending) { + showTimestamp = true; + this._notification.appendMessage(logMessage, true, ['chat-log-message']); + } + } + + if (showTimestamp) + this._notification.appendTimestamp(); + + for (let i = 0; i < pendingMessages.length; i++) + this._notification.appendMessage(pendingMessages[i], true); + + if (pendingMessages.length > 0) + this.showNotification(); + } + + destroy(reason) { + if (this._client.is_handling_channel(this._channel)) { + this._ackMessages(); + // The chat box has been destroyed so it can't + // handle the channel any more. + this._channel.close_async(); + } else { + // Don't indicate any unread messages when the notification + // that represents them has been destroyed. + this._pendingMessages = []; + this.countUpdated(); + } + + // Keep source alive while the channel is open + if (reason != MessageTray.NotificationDestroyedReason.SOURCE_CLOSED) + return; + + if (this._destroyed) + return; + + this._destroyed = true; + this._channel.disconnectObject(this); + this._contact.disconnectObject(this); + + super.destroy(reason); + } + + _channelClosed() { + this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + + /* All messages are new messages for Telepathy sources */ + get count() { + return this._pendingMessages.length; + } + + get unseenCount() { + return this.count; + } + + get countVisible() { + return this.count > 0; + } + + _messageReceived(channel, message) { + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + return; + + this._ensureNotification(); + this._pendingMessages.push(message); + this.countUpdated(); + + message = ChatMessage.newFromTpMessage(message, + NotificationDirection.RECEIVED); + this._notification.appendMessage(message); + + // Wait a bit before notifying for the received message, a handler + // could ack it in the meantime. + if (this._notifyTimeoutId != 0) + GLib.source_remove(this._notifyTimeoutId); + this._notifyTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, + this._notifyTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notifyTimeoutId, '[gnome-shell] this._notifyTimeout'); + } + + _notifyTimeout() { + if (this._pendingMessages.length != 0) + this.showNotification(); + + this._notifyTimeoutId = 0; + + return GLib.SOURCE_REMOVE; + } + + // This is called for both messages we send from + // our client and other clients as well. + _messageSent(channel, message, _flags, _token) { + this._ensureNotification(); + message = ChatMessage.newFromTpMessage(message, + NotificationDirection.SENT); + this._notification.appendMessage(message); + } + + showNotification() { + super.showNotification(this._notification); + } + + respond(text) { + let type; + if (text.slice(0, 4) == '/me ') { + type = Tp.ChannelTextMessageType.ACTION; + text = text.slice(4); + } else { + type = Tp.ChannelTextMessageType.NORMAL; + } + + let msg = Tp.ClientMessage.new_text(type, text); + this._channel.send_message_async(msg, 0); + } + + setChatState(state) { + // We don't want to send COMPOSING every time a letter is typed into + // the entry. We send the state only when it changes. Telepathy/Empathy + // might change it behind our back if the user is using both + // gnome-shell's entry and the Empathy conversation window. We could + // keep track of it with the ChatStateChanged signal but it is good + // enough right now. + if (state != this._chatState) { + this._chatState = state; + this._channel.set_chat_state_async(state, null); + } + } + + _presenceChanged(_contact, _presence, _status, _message) { + if (this._notification) { + this._notification.update(this._notification.title, + this._notification.bannerBodyText, + { secondaryGIcon: this.getSecondaryIcon() }); + } + } + + _pendingRemoved(channel, message) { + let idx = this._pendingMessages.indexOf(message); + + if (idx >= 0) { + this._pendingMessages.splice(idx, 1); + this.countUpdated(); + } + + if (this._pendingMessages.length == 0 && + this._banner && !this._banner.expanded) + this._banner.hide(); + } + + _ackMessages() { + // Don't clear our messages here, tp-glib will send a + // 'pending-message-removed' for each one. + this._channel.ack_all_pending_messages_async(null); + } +}) : null; + +const ChatNotificationMessage = HAVE_TP ? GObject.registerClass( +class ChatNotificationMessage extends GObject.Object { + _init(props = {}) { + super._init(); + this.set(props); + } +}) : null; + +var ChatNotification = HAVE_TP ? GObject.registerClass({ + Signals: { + 'message-removed': { param_types: [ChatNotificationMessage.$gtype] }, + 'message-added': { param_types: [ChatNotificationMessage.$gtype] }, + 'timestamp-changed': { param_types: [ChatNotificationMessage.$gtype] }, + }, +}, class ChatNotification extends MessageTray.Notification { + _init(source) { + super._init(source, source.title, null, + { secondaryGIcon: source.getSecondaryIcon() }); + this.setUrgency(MessageTray.Urgency.HIGH); + this.setResident(true); + + this.messages = []; + this._timestampTimeoutId = 0; + } + + destroy(reason) { + if (this._timestampTimeoutId) + GLib.source_remove(this._timestampTimeoutId); + this._timestampTimeoutId = 0; + super.destroy(reason); + } + + /** + * appendMessage: + * @param {Object} message: An object with the properties + * {string} message.text: the body of the message, + * {Tp.ChannelTextMessageType} message.messageType: the type + * {string} message.sender: the name of the sender, + * {number} message.timestamp: the time the message was sent + * {NotificationDirection} message.direction: a #NotificationDirection + * + * @param {bool} noTimestamp: Whether to add a timestamp. If %true, + * no timestamp will be added, regardless of the difference since + * the last timestamp + */ + appendMessage(message, noTimestamp) { + let messageBody = GLib.markup_escape_text(message.text, -1); + let styles = [message.direction]; + + if (message.messageType == Tp.ChannelTextMessageType.ACTION) { + let senderAlias = GLib.markup_escape_text(message.sender, -1); + messageBody = `<i>${senderAlias}</i> ${messageBody}`; + styles.push('chat-action'); + } + + if (message.direction == NotificationDirection.RECEIVED) { + this.update(this.source.title, messageBody, { + datetime: GLib.DateTime.new_from_unix_local(message.timestamp), + bannerMarkup: true, + }); + } + + let group = message.direction == NotificationDirection.RECEIVED + ? 'received' : 'sent'; + + this._append({ + body: messageBody, + group, + styles, + timestamp: message.timestamp, + noTimestamp, + }); + } + + _filterMessages() { + if (this.messages.length < 1) + return; + + let lastMessageTime = this.messages[0].timestamp; + let currentTime = Date.now() / 1000; + + // Keep the scrollback from growing too long. If the most + // recent message (before the one we just added) is within + // SCROLLBACK_RECENT_TIME, we will keep + // SCROLLBACK_RECENT_LENGTH previous messages. Otherwise + // we'll keep SCROLLBACK_IDLE_LENGTH messages. + + let maxLength = lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME + ? SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH; + + let filteredHistory = this.messages.filter(item => item.realMessage); + if (filteredHistory.length > maxLength) { + let lastMessageToKeep = filteredHistory[maxLength]; + let expired = this.messages.splice(this.messages.indexOf(lastMessageToKeep)); + for (let i = 0; i < expired.length; i++) + this.emit('message-removed', expired[i]); + } + } + + /** + * _append: + * @param {Object} props: An object with the properties: + * {string} props.body: The text of the message. + * {string} props.group: The group of the message, one of: + * 'received', 'sent', 'meta'. + * {string[]} props.styles: Style class names for the message to have. + * {number} props.timestamp: The timestamp of the message. + * {bool} props.noTimestamp: suppress timestamp signal? + */ + _append(props) { + let currentTime = Date.now() / 1000; + props = Params.parse(props, { + body: null, + group: null, + styles: [], + timestamp: currentTime, + noTimestamp: false, + }); + const { noTimestamp } = props; + delete props.noTimestamp; + + // Reset the old message timeout + if (this._timestampTimeoutId) + GLib.source_remove(this._timestampTimeoutId); + this._timestampTimeoutId = 0; + + let message = new ChatNotificationMessage({ + realMessage: props.group !== 'meta', + showTimestamp: false, + ...props, + }); + + this.messages.unshift(message); + this.emit('message-added', message); + + if (!noTimestamp) { + let timestamp = props.timestamp; + if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) { + this.appendTimestamp(); + } else { + // Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME + // from the timestamp of the message. + this._timestampTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp), + this.appendTimestamp.bind(this)); + GLib.Source.set_name_by_id(this._timestampTimeoutId, '[gnome-shell] this.appendTimestamp'); + } + } + + this._filterMessages(); + } + + appendTimestamp() { + this._timestampTimeoutId = 0; + + this.messages[0].showTimestamp = true; + this.emit('timestamp-changed', this.messages[0]); + + this._filterMessages(); + + return GLib.SOURCE_REMOVE; + } + + appendAliasChange(oldAlias, newAlias) { + oldAlias = GLib.markup_escape_text(oldAlias, -1); + newAlias = GLib.markup_escape_text(newAlias, -1); + + /* Translators: this is the other person changing their old IM name to their new + IM name. */ + const message = `<i>${ + _('%s is now known as %s').format(oldAlias, newAlias)}</i>`; + + this._append({ + body: message, + group: 'meta', + styles: ['chat-meta-message'], + }); + + this._filterMessages(); + } +}) : null; + +var ChatLineBox = GObject.registerClass( +class ChatLineBox extends St.BoxLayout { + vfunc_get_preferred_height(forWidth) { + let [, natHeight] = super.vfunc_get_preferred_height(forWidth); + return [natHeight, natHeight]; + } +}); + +var ChatNotificationBanner = GObject.registerClass( +class ChatNotificationBanner extends MessageTray.NotificationBanner { + _init(notification) { + super._init(notification); + + this._responseEntry = new St.Entry({ + style_class: 'chat-response', + x_expand: true, + can_focus: true, + }); + this._responseEntry.clutter_text.connect('activate', this._onEntryActivated.bind(this)); + this._responseEntry.clutter_text.connect('text-changed', this._onEntryChanged.bind(this)); + this.setActionArea(this._responseEntry); + + this._responseEntry.clutter_text.connect('key-focus-in', () => { + this.focused = true; + }); + this._responseEntry.clutter_text.connect('key-focus-out', () => { + this.focused = false; + this.emit('unfocused'); + }); + + this._scrollArea = new St.ScrollView({ + style_class: 'chat-scrollview vfade', + vscrollbar_policy: St.PolicyType.AUTOMATIC, + hscrollbar_policy: St.PolicyType.NEVER, + visible: this.expanded, + }); + this._contentArea = new St.BoxLayout({ + style_class: 'chat-body', + vertical: true, + }); + this._scrollArea.add_actor(this._contentArea); + + this.setExpandedBody(this._scrollArea); + this.setExpandedLines(CHAT_EXPAND_LINES); + + this._lastGroup = null; + + // Keep track of the bottom position for the current adjustment and + // force a scroll to the bottom if things change while we were at the + // bottom + this._oldMaxScrollValue = this._scrollArea.vscroll.adjustment.value; + this._scrollArea.vscroll.adjustment.connect('changed', adjustment => { + if (adjustment.value == this._oldMaxScrollValue) + this.scrollTo(St.Side.BOTTOM); + this._oldMaxScrollValue = Math.max(adjustment.lower, adjustment.upper - adjustment.page_size); + }); + + this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text }); + + this._composingTimeoutId = 0; + + this._messageActors = new Map(); + + this.notification.connectObject( + 'timestamp-changed', (n, message) => this._updateTimestamp(message), + 'message-added', (n, message) => this._addMessage(message), + 'message-removed', (n, message) => { + let actor = this._messageActors.get(message); + if (this._messageActors.delete(message)) + actor.destroy(); + }, this); + + for (let i = this.notification.messages.length - 1; i >= 0; i--) + this._addMessage(this.notification.messages[i]); + } + + scrollTo(side) { + let adjustment = this._scrollArea.vscroll.adjustment; + if (side == St.Side.TOP) + adjustment.value = adjustment.lower; + else if (side == St.Side.BOTTOM) + adjustment.value = adjustment.upper; + } + + hide() { + this.emit('done-displaying'); + } + + _addMessage(message) { + let body = new MessageList.URLHighlighter(message.body, true, true); + + let styles = message.styles; + for (let i = 0; i < styles.length; i++) + body.add_style_class_name(styles[i]); + + let group = message.group; + if (group != this._lastGroup) { + this._lastGroup = group; + body.add_style_class_name('chat-new-group'); + } + + let lineBox = new ChatLineBox(); + lineBox.add(body); + this._contentArea.add_actor(lineBox); + this._messageActors.set(message, lineBox); + + this._updateTimestamp(message); + } + + _updateTimestamp(message) { + let actor = this._messageActors.get(message); + if (!actor) + return; + + while (actor.get_n_children() > 1) + actor.get_child_at_index(1).destroy(); + + if (message.showTimestamp) { + let lastMessageTime = message.timestamp; + let lastMessageDate = new Date(lastMessageTime * 1000); + + let timeLabel = Util.createTimeLabel(lastMessageDate); + timeLabel.style_class = 'chat-meta-message'; + timeLabel.x_expand = timeLabel.y_expand = true; + timeLabel.x_align = timeLabel.y_align = Clutter.ActorAlign.END; + + actor.add_actor(timeLabel); + } + } + + _onEntryActivated() { + let text = this._responseEntry.get_text(); + if (text == '') + return; + + this._inputHistory.addItem(text); + + // Telepathy sends out the Sent signal for us. + // see Source._messageSent + this._responseEntry.set_text(''); + this.notification.source.respond(text); + } + + _composingStopTimeout() { + this._composingTimeoutId = 0; + + this.notification.source.setChatState(Tp.ChannelChatState.PAUSED); + + return GLib.SOURCE_REMOVE; + } + + _onEntryChanged() { + let text = this._responseEntry.get_text(); + + // If we're typing, we want to send COMPOSING. + // If we empty the entry, we want to send ACTIVE. + // If we've stopped typing for COMPOSING_STOP_TIMEOUT + // seconds, we want to send PAUSED. + + // Remove composing timeout. + if (this._composingTimeoutId > 0) { + GLib.source_remove(this._composingTimeoutId); + this._composingTimeoutId = 0; + } + + if (text != '') { + this.notification.source.setChatState(Tp.ChannelChatState.COMPOSING); + + this._composingTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + COMPOSING_STOP_TIMEOUT, + this._composingStopTimeout.bind(this)); + GLib.Source.set_name_by_id(this._composingTimeoutId, '[gnome-shell] this._composingStopTimeout'); + } else { + this.notification.source.setChatState(Tp.ChannelChatState.ACTIVE); + } + } +}); + +var Component = TelepathyComponent; |