summaryrefslogtreecommitdiffstats
path: root/js/ui/components
diff options
context:
space:
mode:
Diffstat (limited to 'js/ui/components')
-rw-r--r--js/ui/components/__init__.js58
-rw-r--r--js/ui/components/automountManager.js256
-rw-r--r--js/ui/components/autorunManager.js345
-rw-r--r--js/ui/components/keyring.js229
-rw-r--r--js/ui/components/networkAgent.js877
-rw-r--r--js/ui/components/polkitAgent.js471
-rw-r--r--js/ui/components/telepathyClient.js1019
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;