// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- /* exported ShellMountOperation, GnomeShellMountOpHandler */ const { Clutter, Gio, GLib, GObject, Pango, Shell, St } = imports.gi; const Signals = imports.signals; const Animation = imports.ui.animation; const CheckBox = imports.ui.checkBox; const Dialog = imports.ui.dialog; const Main = imports.ui.main; const MessageTray = imports.ui.messageTray; const ModalDialog = imports.ui.modalDialog; const Params = imports.misc.params; const ShellEntry = imports.ui.shellEntry; const { loadInterfaceXML } = imports.misc.fileUtils; var LIST_ITEM_ICON_SIZE = 48; var WORK_SPINNER_ICON_SIZE = 16; const REMEMBER_MOUNT_PASSWORD_KEY = 'remember-mount-password'; /* ------ Common Utils ------- */ function _setButtonsForChoices(dialog, choices) { let buttons = []; for (let idx = 0; idx < choices.length; idx++) { let button = idx; buttons.unshift({ label: choices[idx], action: () => dialog.emit('response', button), }); } dialog.setButtons(buttons); } function _setLabelsForMessage(content, message) { let labels = message.split('\n'); content.title = labels.shift(); content.body = labels.join('\n'); } /* -------------------------------------------------------- */ var ListItem = class { constructor(app) { this._app = app; let layout = new St.BoxLayout({ vertical: false }); this.actor = new St.Button({ style_class: 'mount-dialog-app-list-item', can_focus: true, child: layout, reactive: true, x_align: St.Align.START, x_fill: true }); this._icon = this._app.create_icon_texture(LIST_ITEM_ICON_SIZE); let iconBin = new St.Bin({ style_class: 'mount-dialog-app-list-item-icon', child: this._icon }); layout.add(iconBin); this._nameLabel = new St.Label({ text: this._app.get_name(), style_class: 'mount-dialog-app-list-item-name' }); let labelBin = new St.Bin({ y_align: St.Align.MIDDLE, child: this._nameLabel }); layout.add(labelBin); this.actor.connect('clicked', this._onClicked.bind(this)); } _onClicked() { this.emit('activate'); this._app.activate(); } }; Signals.addSignalMethods(ListItem.prototype); var ShellMountOperation = class { constructor(source, params) { params = Params.parse(params, { existingDialog: null }); this._dialog = null; this._dialogId = 0; this._existingDialog = params.existingDialog; this._processesDialog = null; this.mountOp = new Shell.MountOperation(); this.mountOp.connect('ask-question', this._onAskQuestion.bind(this)); this.mountOp.connect('ask-password', this._onAskPassword.bind(this)); this.mountOp.connect('show-processes-2', this._onShowProcesses2.bind(this)); this.mountOp.connect('aborted', this.close.bind(this)); this.mountOp.connect('show-unmount-progress', this._onShowUnmountProgress.bind(this)); this._gicon = source.get_icon(); } _closeExistingDialog() { if (!this._existingDialog) return; this._existingDialog.close(); this._existingDialog = null; } _onAskQuestion(op, message, choices) { this._closeExistingDialog(); this._dialog = new ShellMountQuestionDialog(this._gicon); this._dialogId = this._dialog.connect('response', (object, choice) => { this.mountOp.set_choice(choice); this.mountOp.reply(Gio.MountOperationResult.HANDLED); this.close(); }); this._dialog.update(message, choices); this._dialog.open(); } _onAskPassword(op, message, defaultUser, defaultDomain, flags) { if (this._existingDialog) { this._dialog = this._existingDialog; this._dialog.reaskPassword(); } else { this._dialog = new ShellMountPasswordDialog(message, this._gicon, flags); } this._dialogId = this._dialog.connect('response', (object, choice, password, remember, hiddenVolume, systemVolume, pim) => { if (choice == -1) { this.mountOp.reply(Gio.MountOperationResult.ABORTED); } else { if (remember) this.mountOp.set_password_save(Gio.PasswordSave.PERMANENTLY); else this.mountOp.set_password_save(Gio.PasswordSave.NEVER); this.mountOp.set_password(password); this.mountOp.set_is_tcrypt_hidden_volume(hiddenVolume); this.mountOp.set_is_tcrypt_system_volume(systemVolume); this.mountOp.set_pim(pim); this.mountOp.reply(Gio.MountOperationResult.HANDLED); } }); this._dialog.open(); } close(_op) { this._closeExistingDialog(); this._processesDialog = null; if (this._dialog) { this._dialog.close(); this._dialog = null; } if (this._notifier) { this._notifier.done(); this._notifier = null; } } _onShowProcesses2(op) { this._closeExistingDialog(); let processes = op.get_show_processes_pids(); let choices = op.get_show_processes_choices(); let message = op.get_show_processes_message(); if (!this._processesDialog) { this._processesDialog = new ShellProcessesDialog(this._gicon); this._dialog = this._processesDialog; this._dialogId = this._processesDialog.connect('response', (object, choice) => { if (choice == -1) { this.mountOp.reply(Gio.MountOperationResult.ABORTED); } else { this.mountOp.set_choice(choice); this.mountOp.reply(Gio.MountOperationResult.HANDLED); } this.close(); }); this._processesDialog.open(); } this._processesDialog.update(message, processes, choices); } _onShowUnmountProgress(op, message, timeLeft, bytesLeft) { if (!this._notifier) this._notifier = new ShellUnmountNotifier(); if (bytesLeft == 0) this._notifier.done(message); else this._notifier.show(message); } borrowDialog() { if (this._dialogId != 0) { this._dialog.disconnect(this._dialogId); this._dialogId = 0; } return this._dialog; } }; var ShellUnmountNotifier = GObject.registerClass( class ShellUnmountNotifier extends MessageTray.Source { _init() { super._init('', 'media-removable'); this._notification = null; Main.messageTray.add(this); } show(message) { let [header, text] = message.split('\n', 2); if (!this._notification) { this._notification = new MessageTray.Notification(this, header, text); this._notification.setTransient(true); this._notification.setUrgency(MessageTray.Urgency.CRITICAL); } else { this._notification.update(header, text); } this.showNotification(this._notification); } done(message) { if (this._notification) { this._notification.destroy(); this._notification = null; } if (message) { let notification = new MessageTray.Notification(this, message, null); notification.setTransient(true); this.showNotification(notification); } } }); var ShellMountQuestionDialog = GObject.registerClass({ Signals: { 'response': { param_types: [GObject.TYPE_INT] } } }, class ShellMountQuestionDialog extends ModalDialog.ModalDialog { _init(icon) { super._init({ styleClass: 'mount-dialog' }); this._content = new Dialog.MessageDialogContent({ icon }); this.contentLayout.add(this._content, { x_fill: true, y_fill: false }); } update(message, choices) { _setLabelsForMessage(this._content, message); _setButtonsForChoices(this, choices); } }); var ShellMountPasswordDialog = GObject.registerClass({ Signals: { 'response': { param_types: [GObject.TYPE_INT, GObject.TYPE_STRING, GObject.TYPE_BOOLEAN, GObject.TYPE_BOOLEAN, GObject.TYPE_BOOLEAN, GObject.TYPE_UINT] } } }, class ShellMountPasswordDialog extends ModalDialog.ModalDialog { _init(message, icon, flags) { let strings = message.split('\n'); let title = strings.shift() || null; let body = strings.shift() || null; super._init({ styleClass: 'prompt-dialog' }); let disksApp = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop'); let content = new Dialog.MessageDialogContent({ icon, title, body }); this.contentLayout.add_actor(content); content._body.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); let grid = new St.Widget({ style_class: 'prompt-dialog-grid', layout_manager: layout }); layout.hookup_style(grid); let rtl = grid.get_text_direction() === Clutter.TextDirection.RTL; if (flags & Gio.AskPasswordFlags.TCRYPT) { this._keyfilesLabel = new St.Label(({ style_class: 'prompt-dialog-keyfiles-label', visible: false })); this._hiddenVolume = new CheckBox.CheckBox(_("Hidden Volume")); content.messageBox.add(this._hiddenVolume.actor); this._systemVolume = new CheckBox.CheckBox(_("Windows System Volume")); content.messageBox.add(this._systemVolume.actor); this._keyfilesCheckbox = new CheckBox.CheckBox(_("Uses Keyfiles")); this._keyfilesCheckbox.actor.connect("clicked", this._onKeyfilesCheckboxClicked.bind(this)); content.messageBox.add(this._keyfilesCheckbox.actor); this._keyfilesLabel.clutter_text.set_markup( /* Translators: %s is the Disks application */ _("To unlock a volume that uses keyfiles, use the %s utility instead.").format(disksApp.get_name()) ); this._keyfilesLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; this._keyfilesLabel.clutter_text.line_wrap = true; content.messageBox.add(this._keyfilesLabel, { y_fill: false, y_align: St.Align.MIDDLE, expand: true }); this._pimLabel = new St.Label({ style_class: 'prompt-dialog-password-label', text: _("PIM Number"), y_align: Clutter.ActorAlign.CENTER }); this._pimEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', can_focus: true, x_expand: true }); this._pimEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); this._pimEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE ShellEntry.addContextMenu(this._pimEntry, { isPassword: true }); if (rtl) { layout.attach(this._pimEntry, 0, 0, 1, 1); layout.attach(this._pimLabel, 1, 0, 1, 1); } else { layout.attach(this._pimLabel, 0, 0, 1, 1); layout.attach(this._pimEntry, 1, 0, 1, 1); } this._pimErrorMessageLabel = new St.Label({ style_class: 'prompt-dialog-password-entry', text: _("The PIM must be a number or empty."), visible: false }); layout.attach(this._pimErrorMessageLabel, 0, 2, 2, 1); } else { this._hiddenVolume = null; this._systemVolume = null; this._pimEntry = null; this._pimErrorMessageLabel = null; } this._passwordLabel = new St.Label({ style_class: 'prompt-dialog-password-label', text: _("Password"), y_align: Clutter.ActorAlign.CENTER }); this._passwordEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', can_focus: true, x_expand: true }); this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); this._passwordEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE ShellEntry.addContextMenu(this._passwordEntry, { isPassword: true }); this.setInitialKeyFocus(this._passwordEntry); this._workSpinner = new Animation.Spinner(WORK_SPINNER_ICON_SIZE, true); this._passwordEntry.secondary_icon = this._workSpinner.actor; if (rtl) { layout.attach(this._passwordEntry, 0, 1, 1, 1); layout.attach(this._passwordLabel, 1, 1, 1, 1); } else { layout.attach(this._passwordLabel, 0, 1, 1, 1); layout.attach(this._passwordEntry, 1, 1, 1, 1); } content.messageBox.add(grid); this._errorMessageLabel = new St.Label({ style_class: 'prompt-dialog-error-label', text: _("Sorry, that didn’t work. Please try again.") }); this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; this._errorMessageLabel.clutter_text.line_wrap = true; this._errorMessageLabel.hide(); content.messageBox.add(this._errorMessageLabel); if (flags & Gio.AskPasswordFlags.SAVING_SUPPORTED) { this._rememberChoice = new CheckBox.CheckBox(_("Remember Password")); this._rememberChoice.actor.checked = global.settings.get_boolean(REMEMBER_MOUNT_PASSWORD_KEY); content.messageBox.add(this._rememberChoice.actor); } else { this._rememberChoice = null; } this._defaultButtons = [{ label: _("Cancel"), action: this._onCancelButton.bind(this), key: Clutter.Escape, }, { label: _("Unlock"), action: this._onUnlockButton.bind(this), default: true, }]; this._usesKeyfilesButtons = [{ label: _("Cancel"), action: this._onCancelButton.bind(this), key: Clutter.Escape, }, { /* Translators: %s is the Disks application */ label: _("Open %s").format(disksApp.get_name()), action: this._onOpenDisksButton.bind(this), default: true, }]; this.setButtons(this._defaultButtons); } reaskPassword() { this._passwordEntry.set_text(''); this._errorMessageLabel.show(); this._workSpinner.stop(); } _onCancelButton() { this.emit('response', -1, '', false, false, false, 0); } _onUnlockButton() { this._onEntryActivate(); } _onEntryActivate() { let pim = 0; if (this._pimEntry !== null) pim = this._pimEntry.get_text(); if (isNaN(pim)) { this._pimEntry.set_text(''); this._pimErrorMessageLabel.show(); return; } else if (this._pimErrorMessageLabel !== null) { this._pimErrorMessageLabel.hide(); } global.settings.set_boolean(REMEMBER_MOUNT_PASSWORD_KEY, this._rememberChoice && this._rememberChoice.actor.checked); this._workSpinner.play(); this.emit('response', 1, this._passwordEntry.get_text(), this._rememberChoice && this._rememberChoice.actor.checked, this._hiddenVolume && this._hiddenVolume.actor.checked, this._systemVolume && this._systemVolume.actor.checked, parseInt(pim)); } _onKeyfilesCheckboxClicked() { let useKeyfiles = this._keyfilesCheckbox.actor.checked; this._passwordEntry.reactive = !useKeyfiles; this._passwordEntry.can_focus = !useKeyfiles; this._passwordEntry.clutter_text.editable = !useKeyfiles; this._passwordEntry.clutter_text.selectable = !useKeyfiles; this._pimEntry.reactive = !useKeyfiles; this._pimEntry.can_focus = !useKeyfiles; this._pimEntry.clutter_text.editable = !useKeyfiles; this._pimEntry.clutter_text.selectable = !useKeyfiles; this._rememberChoice.actor.reactive = !useKeyfiles; this._rememberChoice.actor.can_focus = !useKeyfiles; this._keyfilesLabel.visible = useKeyfiles; this.setButtons(useKeyfiles ? this._usesKeyfilesButtons : this._defaultButtons); } _onOpenDisksButton() { let app = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop'); if (app) app.activate(); else Main.notifyError( /* Translators: %s is the Disks application */ _("Unable to start %s").format(app.get_name()), /* Translators: %s is the Disks application */ _("Couldn’t find the %s application").format(app.get_name()) ); this._onCancelButton(); } }); var ShellProcessesDialog = GObject.registerClass({ Signals: { 'response': { param_types: [GObject.TYPE_INT] } } }, class ShellProcessesDialog extends ModalDialog.ModalDialog { _init(icon) { super._init({ styleClass: 'mount-dialog' }); this._content = new Dialog.MessageDialogContent({ icon }); this.contentLayout.add(this._content, { x_fill: true, y_fill: false }); let scrollView = new St.ScrollView({ style_class: 'mount-dialog-app-list' }); scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC); this.contentLayout.add(scrollView, { x_fill: true, y_fill: true }); scrollView.hide(); this._applicationList = new St.BoxLayout({ vertical: true }); scrollView.add_actor(this._applicationList); this._applicationList.connect('actor-added', () => { if (this._applicationList.get_n_children() == 1) scrollView.show(); }); this._applicationList.connect('actor-removed', () => { if (this._applicationList.get_n_children() == 0) scrollView.hide(); }); } _setAppsForPids(pids) { // remove all the items this._applicationList.destroy_all_children(); pids.forEach(pid => { let tracker = Shell.WindowTracker.get_default(); let app = tracker.get_app_from_pid(pid); if (!app) return; let item = new ListItem(app); this._applicationList.add(item.actor, { x_fill: true }); item.connect('activate', () => { // use -1 to indicate Cancel this.emit('response', -1); }); }); } update(message, processes, choices) { this._setAppsForPids(processes); _setLabelsForMessage(this._content, message); _setButtonsForChoices(this, choices); } }); const GnomeShellMountOpIface = loadInterfaceXML('org.Gtk.MountOperationHandler'); var ShellMountOperationType = { NONE: 0, ASK_PASSWORD: 1, ASK_QUESTION: 2, SHOW_PROCESSES: 3 }; var GnomeShellMountOpHandler = class { constructor() { this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellMountOpIface, this); this._dbusImpl.export(Gio.DBus.session, '/org/gtk/MountOperationHandler'); Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.gtk.MountOperationHandler', Gio.BusNameOwnerFlags.REPLACE, null, null); this._dialog = null; this._volumeMonitor = Gio.VolumeMonitor.get(); this._ensureEmptyRequest(); } _ensureEmptyRequest() { this._currentId = null; this._currentInvocation = null; this._currentType = ShellMountOperationType.NONE; } _clearCurrentRequest(response, details) { if (this._currentInvocation) { this._currentInvocation.return_value( GLib.Variant.new('(ua{sv})', [response, details])); } this._ensureEmptyRequest(); } _setCurrentRequest(invocation, id, type) { let oldId = this._currentId; let oldType = this._currentType; let requestId = `${id}@${invocation.get_sender()}`; this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {}); this._currentInvocation = invocation; this._currentId = requestId; this._currentType = type; if (this._dialog && (oldId == requestId) && (oldType == type)) return true; return false; } _closeDialog() { if (this._dialog) { this._dialog.close(); this._dialog = null; } } _createGIcon(iconName) { let realIconName = iconName ? iconName : 'drive-harddisk'; return new Gio.ThemedIcon({ name: realIconName, use_default_fallbacks: true }); } /** * AskPassword: * @id: an opaque ID identifying the object for which the operation is requested * The ID must be unique in the context of the calling process. * @message: the message to display * @icon_name: the name of an icon to display * @default_user: the default username for display * @default_domain: the default domain for display * @flags: a set of GAskPasswordFlags * @response: a GMountOperationResult * @response_details: a dictionary containing the response details as * entered by the user. The dictionary MAY contain the following properties: * - "password" -> (s): a password to be used to complete the mount operation * - "password_save" -> (u): a GPasswordSave * * The dialog will stay visible until clients call the Close() method, or * another dialog becomes visible. * Calling AskPassword again for the same id will have the effect to clear * the existing dialog and update it with a message indicating the previous * attempt went wrong. */ AskPasswordAsync(params, invocation) { let [id, message, iconName, defaultUser_, defaultDomain_, flags] = params; if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_PASSWORD)) { this._dialog.reaskPassword(); return; } this._closeDialog(); this._dialog = new ShellMountPasswordDialog(message, this._createGIcon(iconName), flags); this._dialog.connect('response', (object, choice, password, remember, hiddenVolume, systemVolume, pim) => { let details = {}; let response; if (choice == -1) { response = Gio.MountOperationResult.ABORTED; } else { response = Gio.MountOperationResult.HANDLED; let passSave = remember ? Gio.PasswordSave.PERMANENTLY : Gio.PasswordSave.NEVER; details['password_save'] = GLib.Variant.new('u', passSave); details['password'] = GLib.Variant.new('s', password); details['hidden_volume'] = GLib.Variant.new('b', hiddenVolume); details['system_volume'] = GLib.Variant.new('b', systemVolume); details['pim'] = GLib.Variant.new('u', pim); } this._clearCurrentRequest(response, details); }); this._dialog.open(); } /** * AskQuestion: * @id: an opaque ID identifying the object for which the operation is requested * The ID must be unique in the context of the calling process. * @message: the message to display * @icon_name: the name of an icon to display * @choices: an array of choice strings * GetResponse: * @response: a GMountOperationResult * @response_details: a dictionary containing the response details as * entered by the user. The dictionary MAY contain the following properties: * - "choice" -> (i): the chosen answer among the array of strings passed in * * The dialog will stay visible until clients call the Close() method, or * another dialog becomes visible. * Calling AskQuestion again for the same id will have the effect to clear * update the dialog with the new question. */ AskQuestionAsync(params, invocation) { let [id, message, iconName, choices] = params; if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_QUESTION)) { this._dialog.update(message, choices); return; } this._closeDialog(); this._dialog = new ShellMountQuestionDialog(this._createGIcon(iconName), message); this._dialog.connect('response', (object, choice) => { this._clearCurrentRequest(Gio.MountOperationResult.HANDLED, { choice: GLib.Variant.new('i', choice) }); }); this._dialog.update(message, choices); this._dialog.open(); } /** * ShowProcesses: * @id: an opaque ID identifying the object for which the operation is requested * The ID must be unique in the context of the calling process. * @message: the message to display * @icon_name: the name of an icon to display * @application_pids: the PIDs of the applications to display * @choices: an array of choice strings * @response: a GMountOperationResult * @response_details: a dictionary containing the response details as * entered by the user. The dictionary MAY contain the following properties: * - "choice" -> (i): the chosen answer among the array of strings passed in * * The dialog will stay visible until clients call the Close() method, or * another dialog becomes visible. * Calling ShowProcesses again for the same id will have the effect to clear * the existing dialog and update it with the new message and the new list * of processes. */ ShowProcessesAsync(params, invocation) { let [id, message, iconName, applicationPids, choices] = params; if (this._setCurrentRequest(invocation, id, ShellMountOperationType.SHOW_PROCESSES)) { this._dialog.update(message, applicationPids, choices); return; } this._closeDialog(); this._dialog = new ShellProcessesDialog(this._createGIcon(iconName)); this._dialog.connect('response', (object, choice) => { let response; let details = {}; if (choice == -1) { response = Gio.MountOperationResult.ABORTED; } else { response = Gio.MountOperationResult.HANDLED; details['choice'] = GLib.Variant.new('i', choice); } this._clearCurrentRequest(response, details); }); this._dialog.update(message, applicationPids, choices); this._dialog.open(); } /** * Close: * * Closes a dialog previously opened by AskPassword, AskQuestion or ShowProcesses. * If no dialog is open, does nothing. */ Close(_params, _invocation) { this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {}); this._closeDialog(); } };