From 45d55003bd633cea6c1a8f28bb5dbeb6eadeb3c8 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Thu, 16 Oct 2025 14:46:08 -0400 Subject: [PATCH 1/7] Implement mount/unmount user interaction in Cinnamon. Certain aspects of GtkMountOperation are broken under Wayland in Gtk3 and won't be fixed. Fortunately it can also use a dbus interface (org.gtk.MountOperationHandler) if a provider exists. This allows us to: - provide the 'device is in use' popup and showing which application is using it, when trying to eject a device. - provide the password/question dialog when mounting devices that require interaction before mounting. - eliminate a lot of code and translations from placesManager, and make the behavior identical when interacting with a device whether from a file manager or Cinnamon's drives applet. Translations are provided by Gtk, Gvfs instead (as when ejecting a device from a file manager). --- docs/reference/cinnamon/meson.build | 1 + js/ui/cinnamonMountOperation.js | 795 ++++++++++++++++++++++++++++ js/ui/main.js | 3 + js/ui/placesManager.js | 160 +----- src/cinnamon-mount-operation.c | 189 +++++++ src/cinnamon-mount-operation.h | 41 ++ src/meson.build | 2 + 7 files changed, 1037 insertions(+), 154 deletions(-) create mode 100644 js/ui/cinnamonMountOperation.js create mode 100644 src/cinnamon-mount-operation.c create mode 100644 src/cinnamon-mount-operation.h diff --git a/docs/reference/cinnamon/meson.build b/docs/reference/cinnamon/meson.build index 0e63e730fd..09fde87bb2 100644 --- a/docs/reference/cinnamon/meson.build +++ b/docs/reference/cinnamon/meson.build @@ -1,6 +1,7 @@ ignore = [ 'cinnamon-recorder-src.h', 'cinnamon-recorder.h', + 'cinnamon-mount-operation.h', st_headers, st_private_headers, tray_headers, diff --git a/js/ui/cinnamonMountOperation.js b/js/ui/cinnamonMountOperation.js new file mode 100644 index 0000000000..98c01504e3 --- /dev/null +++ b/js/ui/cinnamonMountOperation.js @@ -0,0 +1,795 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported CinnamonMountOperation, CinnamonMountOpHandler */ + +const { Clutter, Gio, GLib, GObject, Pango, Cinnamon, St } = imports.gi; + +// 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 CinnamonEntry = imports.ui.cinnamonEntry; + +const Util = imports.misc.util; + +var LIST_ITEM_ICON_SIZE = 48; +var WORK_SPINNER_ICON_SIZE = 16; + +const REMEMBER_MOUNT_PASSWORD_KEY = 'remember-mount-password'; + +const MountOperationHandlerIface = +' \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ +'; + +/* ------ Common Utils ------- */ +function _setButtonsForChoices(dialog, oldChoices, choices) { + let buttons = []; + let buttonsChanged = oldChoices.length !== choices.length; + + for (let idx = 0; idx < choices.length; idx++) { + let button = idx; + + buttonsChanged ||= oldChoices[idx] !== choices[idx]; + + buttons.unshift({ + label: choices[idx], + action: () => dialog.emit('response', button), + }); + } + + if (buttonsChanged) + dialog.setButtons(buttons); +} + +function _setLabelsForMessage(content, message) { + let labels = message.split('\n'); + + content.title = labels.shift(); + content.description = labels.join('\n'); +} + +/* -------------------------------------------------------- */ + +var CinnamonMountOperation = class { + constructor(source, params) { + params = Params.parse(params, { existingDialog: null }); + + this._dialog = null; + this._existingDialog = params.existingDialog; + this._processesDialog = null; + + this.mountOp = new Cinnamon.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)); + } + + _closeExistingDialog() { + if (!this._existingDialog) + return; + + this._existingDialog.close(); + this._existingDialog = null; + } + + _onAskQuestion(op, message, choices) { + global.log("askQuestion", message, choices); + this._closeExistingDialog(); + this._dialog = new CinnamonMountQuestionDialog(); + + this._dialog.connectObject('response', + (object, choice) => { + this.mountOp.set_choice(choice); + this.mountOp.reply(Gio.MountOperationResult.HANDLED); + + this.close(); + }, this); + + this._dialog.update(message, choices); + this._dialog.open(); + } + + _onAskPassword(op, message, defaultUser, defaultDomain, flags) { + global.log("askPassword", message, defaultUser, defaultDomain, flags); + if (this._existingDialog) { + this._dialog = this._existingDialog; + this._dialog.reaskPassword(); + } else { + this._dialog = new CinnamonMountPasswordDialog(message, flags); + } + + this._dialog.connectObject('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); + 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(); + global.log("showProcesses"); + 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 CinnamonProcessesDialog(); + this._dialog = this._processesDialog; + + this._processesDialog.connectObject('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); + this._processesDialog.open(); + } + + this._processesDialog.update(message, processes, choices); + } + + _onShowUnmountProgress(op, message, timeLeft, bytesLeft) { + global.log("show unmount prog", message, timeLeft, bytesLeft); + if (!this._notifier) + this._notifier = new CinnamonUnmountNotifier(); + + if (bytesLeft == 0) + this._notifier.done(message); + else + this._notifier.show(message); + } + + borrowDialog() { + this._dialog?.disconnectObject(this); + return this._dialog; + } +}; + +var CinnamonUnmountNotifier = class extends MessageTray.Source { + constructor() { + super('unmount-notifier'); + + 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.notify(this._notification); + } + + done(message) { + if (this._notification) { + this._notification.destroy(); + this._notification = null; + } + + if (message) { + let [header, text] = message.split('\n', 2); + let notification = new MessageTray.Notification(this, header, text); + notification.setTransient(true); + + this.notify(notification); + } + } + + createNotificationIcon () { + return new St.Icon({ + icon_name: 'xapp-media-removable', + icon_type: St.IconType.SYMBOLIC, + icon_size: this.ICON_SIZE + }); + } +}; + +var CinnamonMountQuestionDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_INT] } }, +}, class CinnamonMountQuestionDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'mount-question-dialog' }); + + this._oldChoices = []; + + this._content = new Dialog.MessageDialogContent(); + this.contentLayout.add_child(this._content); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.emit('response', -1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + update(message, choices) { + _setLabelsForMessage(this._content, message); + _setButtonsForChoices(this, this._oldChoices, choices); + this._oldChoices = choices; + } +}); + +var CinnamonMountPasswordDialog = 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 CinnamonMountPasswordDialog extends ModalDialog.ModalDialog { + _init(message, flags) { + let strings = message.split('\n'); + let title = strings.shift() || null; + let description = strings.shift() || null; + super._init({ styleClass: 'prompt-dialog' }); + + let disksApp = Cinnamon.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop'); + + let content = new Dialog.MessageDialogContent({ title, description }); + + let passwordGridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); + let passwordGrid = new St.Widget({ + style_class: 'prompt-dialog-password-grid', + layout_manager: passwordGridLayout, + }); + passwordGridLayout.hookup_style(passwordGrid); + + let rtl = passwordGrid.get_text_direction() === Clutter.TextDirection.RTL; + let curGridRow = 0; + + if (flags & Gio.AskPasswordFlags.TCRYPT) { + this._hiddenVolume = new CheckBox.CheckBox(_("Hidden Volume")); + content.add_child(this._hiddenVolume); + + this._systemVolume = new CheckBox.CheckBox(_("Windows System Volume")); + content.add_child(this._systemVolume); + + this._keyfilesCheckbox = new CheckBox.CheckBox(_("Uses Keyfiles")); + this._keyfilesCheckbox.connect("clicked", this._onKeyfilesCheckboxClicked.bind(this)); + content.add_child(this._keyfilesCheckbox); + + this._keyfilesLabel = new St.Label({ visible: false }); + if (disksApp) { + 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())); + } else { + this._keyfilesLabel.clutter_text.set_markup( + _('You need an external utility like Disks to unlock a volume that uses keyfiles.')); + } + this._keyfilesLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._keyfilesLabel.clutter_text.line_wrap = true; + content.add_child(this._keyfilesLabel); + + this._pimEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + hint_text: _('PIM Number'), + can_focus: true, + x_expand: true, + }); + this._pimEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + CinnamonEntry.addContextMenu(this._pimEntry); + + if (rtl) + passwordGridLayout.attach(this._pimEntry, 1, curGridRow, 1, 1); + else + passwordGridLayout.attach(this._pimEntry, 0, curGridRow, 1, 1); + curGridRow += 1; + } else { + this._hiddenVolume = null; + this._systemVolume = null; + this._pimEntry = null; + } + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + hint_text: _('Password'), + can_focus: true, + x_expand: true, + }); + this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + this.setInitialKeyFocus(this._passwordEntry); + CinnamonEntry.addContextMenu(this._passwordEntry); + + // this._workSpinner = new Animation.Spinner(WORK_SPINNER_ICON_SIZE, { + // animate: true, + // }); + + if (rtl) { + // passwordGridLayout.attach(this._workSpinner, 0, curGridRow, 1, 1); + passwordGridLayout.attach(this._passwordEntry, 1, curGridRow, 1, 1); + } else { + passwordGridLayout.attach(this._passwordEntry, 0, curGridRow, 1, 1); + // passwordGridLayout.attach(this._workSpinner, 1, curGridRow, 1, 1); + } + curGridRow += 1; + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new CinnamonEntry.CapsLockWarning(); + warningBox.add_child(capsLockWarning); + + this._errorMessageLabel = new St.Label({ + style_class: 'prompt-dialog-error-label', + opacity: 0, + }); + this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._errorMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._errorMessageLabel); + + passwordGridLayout.attach(warningBox, 0, curGridRow, 2, 1); + + content.add_child(passwordGrid); + + // if (flags & Gio.AskPasswordFlags.SAVING_SUPPORTED) { + // this._rememberChoice = new CheckBox.CheckBox(_("Remember Password")); + // // this._rememberChoice.checked = + // // global.settings.get_boolean(REMEMBER_MOUNT_PASSWORD_KEY); + // content.add_child(this._rememberChoice); + // } else { + // this._rememberChoice = null; + // } + + this.contentLayout.add_child(content); + + this._defaultButtons = [{ + label: _("Cancel"), + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }, { + label: _("Unlock"), + action: this._onUnlockButton.bind(this), + default: true, + }]; + + this._usesKeyfilesButtons = [{ + label: _("Cancel"), + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }]; + + if (disksApp) { + this._usesKeyfilesButtons.push({ + /* Translators: %s is the Disks application */ + label: _('Open %s').format(disksApp.get_name()), + action: () => { + disksApp.activate(); + this._onCancelButton(); + }, + default: true, + }); + } + + this.setButtons(this._defaultButtons); + } + + reaskPassword() { + // this._workSpinner.stop(); + this._passwordEntry.set_text(''); + this._errorMessageLabel.text = _('Sorry, that didn’t work. Please try again.'); + this._errorMessageLabel.opacity = 255; + + Util.wiggle(this._passwordEntry); + } + + _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._errorMessageLabel.text = _('The PIM must be a number or empty.'); + this._errorMessageLabel.opacity = 255; + return; + } + + this._errorMessageLabel.opacity = 0; + } + + // global.settings.set_boolean(REMEMBER_MOUNT_PASSWORD_KEY, + // this._rememberChoice && this._rememberChoice.checked); + + // this._workSpinner.play(); + this.emit('response', 1, + this._passwordEntry.get_text(), + this._rememberChoice && + this._rememberChoice.checked, + this._hiddenVolume && + this._hiddenVolume.checked, + this._systemVolume && + this._systemVolume.checked, + parseInt(pim)); + } + + _onKeyfilesCheckboxClicked() { + let useKeyfiles = this._keyfilesCheckbox.checked; + this._passwordEntry.reactive = !useKeyfiles; + this._passwordEntry.can_focus = !useKeyfiles; + this._pimEntry.reactive = !useKeyfiles; + this._pimEntry.can_focus = !useKeyfiles; + this._rememberChoice.reactive = !useKeyfiles; + this._rememberChoice.can_focus = !useKeyfiles; + this._keyfilesLabel.visible = useKeyfiles; + this.setButtons(useKeyfiles ? this._usesKeyfilesButtons : this._defaultButtons); + } +}); + +var CinnamonProcessesDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_INT] } }, +}, class CinnamonProcessesDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'processes-dialog' }); + + this._oldChoices = []; + + this._content = new Dialog.MessageDialogContent(); + this.contentLayout.add_child(this._content); + + this._applicationSection = new Dialog.ListSection(); + this._applicationSection.hide(); + this.contentLayout.add_child(this._applicationSection); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.emit('response', -1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _setAppsForPids(pids) { + // remove all the items + this._applicationSection.list.destroy_all_children(); + global.log(pids); + pids.forEach(pid => { + let tracker = Cinnamon.WindowTracker.get_default(); + let app = tracker.get_app_from_pid(pid); + + if (!app) + return; + + let listItem = new Dialog.ListSectionItem({ + icon_actor: app.create_icon_texture(LIST_ITEM_ICON_SIZE), + title: app.get_name(), + }); + this._applicationSection.list.add_child(listItem); + }); + + this._applicationSection.visible = + this._applicationSection.list.get_n_children() > 0; + } + + update(message, processes, choices) { + this._setAppsForPids(processes); + _setLabelsForMessage(this._content, message); + _setButtonsForChoices(this, this._oldChoices, choices); + this._oldChoices = choices; + } +}); + +var CinnamonMountOperationType = { + NONE: 0, + ASK_PASSWORD: 1, + ASK_QUESTION: 2, + SHOW_PROCESSES: 3, +}; + +var CinnamonMountOpHandler = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(MountOperationHandlerIface, 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._ensureEmptyRequest(); + } + + _ensureEmptyRequest() { + this._currentId = null; + this._currentInvocation = null; + this._currentType = CinnamonMountOperationType.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; + } + } + + /** + * AskPassword: + * @param {Array} params + * {string} id: an opaque ID identifying the object for which + * the operation is requested + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {string} default_user: the default username for display + * {string} default_domain: the default domain for display + * {Gio.AskPasswordFlags} flags: a set of GAskPasswordFlags + * {Gio.MountOperationResults} response: a GMountOperationResult + * {Object} response_details: a dictionary containing 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 + * @param {Gio.DBusMethodInvocation} invocation + * The ID must be unique in the context of the calling process. + * + * 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; + global.log("ask pass", message); + if (this._setCurrentRequest(invocation, id, CinnamonMountOperationType.ASK_PASSWORD)) { + this._dialog.reaskPassword(); + return; + } + + this._closeDialog(); + + this._dialog = new CinnamonMountPasswordDialog(message, 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: + * @param {Array} params - params + * {string} 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. + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {string[]} choices: an array of choice strings + * @param {Gio.DBusMethodInvocation} invocation - invocation + * + * 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; + global.log("ask question", message); + + if (this._setCurrentRequest(invocation, id, CinnamonMountOperationType.ASK_QUESTION)) { + this._dialog.update(message, choices); + return; + } + + this._closeDialog(); + + this._dialog = new CinnamonMountQuestionDialog(message); + 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, choices); + this._dialog.open(); + } + + /** + * ShowProcesses: + * @param {Array} params - params + * {string} 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. + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {number[]} application_pids: the PIDs of the applications to display + * {string[]} choices: an array of choice strings + * @param {Gio.DBusMethodInvocation} invocation - invocation + * + * 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, CinnamonMountOperationType.SHOW_PROCESSES)) { + this._dialog.update(message, applicationPids, choices); + return; + } + + this._closeDialog(); + global.log("show processes "); + this._dialog = new CinnamonProcessesDialog(); + 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: + * @param {Array} _params - params + * @param {Gio.DBusMethodInvocation} _invocation - invocation + * + * 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(); + } +}; diff --git a/js/ui/main.js b/js/ui/main.js index 912bdcbcb8..816fa06083 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -116,6 +116,7 @@ const NetworkAgent = imports.ui.networkAgent; const NotificationDaemon = imports.ui.notificationDaemon; const WindowAttentionHandler = imports.ui.windowAttentionHandler; const CinnamonDBus = imports.ui.cinnamonDBus; +const CinnamonMountOperation = imports.ui.cinnamonMountOperation; const Screenshot = imports.ui.screenshot; const ScreensaverController = imports.ui.screensaver.controller; const ThemeManager = imports.ui.themeManager; @@ -165,6 +166,7 @@ var windowAttentionHandler = null; var screenRecorder = null; var cinnamonAudioSelectionDBusService = null; var cinnamonDBusService = null; +var cinnamonMountOpDBusService = null; var screenshotService = null; var modalCount = 0; var modalActorFocusStack = []; @@ -345,6 +347,7 @@ function start() { new CinnamonPortalHandler(); cinnamonAudioSelectionDBusService = new AudioDeviceSelection.AudioDeviceSelectionDBus(); cinnamonDBusService = new CinnamonDBus.CinnamonDBus(); + cinnamonMountOpDBusService = new CinnamonMountOperation.CinnamonMountOpHandler(); setRunState(RunState.STARTUP); screenshotService = new Screenshot.ScreenshotService(); diff --git a/js/ui/placesManager.js b/js/ui/placesManager.js index 8e232d8ed3..5d932fb027 100644 --- a/js/ui/placesManager.js +++ b/js/ui/placesManager.js @@ -8,6 +8,7 @@ const Mainloop = imports.mainloop; const Signals = imports.signals; const St = imports.gi.St; +const CinnamonMountOperation = imports.ui.cinnamonMountOperation; const Main = imports.ui.main; const MessageTray = imports.ui.messageTray; const Params = imports.misc.params; @@ -125,172 +126,23 @@ PlaceDeviceInfo.prototype = { if (!this.isRemovable()) return; - let mountOp = new Gio.MountOperation(); + let mountOp = new CinnamonMountOperation.CinnamonMountOperation(); let drive = this._mount.get_drive(); let volume = this._mount.get_volume(); if (drive && drive.get_start_stop_type() == Gio.DriveStartStopType.SHUTDOWN && drive.can_stop()) { - drive.stop(0, mountOp, null, Lang.bind(this, this._stopFinish)); + drive.stop(0, mountOp.mountOp, null, null); } else { - if (drive && drive.can_eject()) - drive.eject_with_operation(0, mountOp, null, Lang.bind(this, this._ejectFinish, true)); - else if (volume && volume.can_eject()) - volume.eject_with_operation(0, mountOp, null, Lang.bind(this, this._ejectFinish, false)); - else if (this._mount.can_eject()) - this._mount.eject_with_operation(0, mountOp, null, Lang.bind(this, this._ejectFinish, false)); + if ((drive && drive.can_eject()) || (volume && volume.can_eject()) || this._mount.can_eject()) + drive.eject_with_operation(0, mountOp.mountOp, null, null); else if (this._mount.can_unmount()) - this._mount.unmount_with_operation(0, mountOp, null, Lang.bind(this, this._removeFinish)); + this._mount.unmount_with_operation(0, mountOp.mountOp, null, null); } this.busyWaitId = 0; return false; - }, - - _sendNotification: function(msg1, msg2 = null, withButton = false, persistent = false) { - if (Main.messageTray) { - if (persistent && this.busyNotification != null) { - return; - } - - if (!persistent && this.busyNotification) { - this.busyNotification.destroy(); - this.busyNotification = null; - } - - let source = new MessageTray.SystemNotificationSource(); - Main.messageTray.add(source); - let notification = new MessageTray.Notification(source, msg1, msg2); - notification.setTransient(true); - notification.setUrgency(persistent ? MessageTray.Urgency.CRITICAL : MessageTray.Urgency.NORMAL); - if (withButton) { - notification.addButton('system-undo', _("Retry")); - notification.connect('action-invoked', Lang.bind(this, this.remove)); - } - source.notify(notification); - if (persistent) { - this.busyNotification = notification; - this.destroySignalId = notification.connect("destroy", () => { - this.busyNotification.disconnect(this.destroySignalId); - this.busyNotification = null; - this.destroySignalId = 0; - }) - } - } else { - if (msg2) - global.log(msg1 + ': ' + msg2); - else - global.log(msg1); - } - }, - - _stopFinish: function(drive, res) { - if (DEBUG) global.log("PlacesManager: **_stopFinish**"); - let driveName = drive.get_name(); // Ex: USB Flash Drive - let unixDevice = drive.get_identifier('unix-device'); // Ex: /dev/sdc - let msg1 = _("%s (%s) has just been stopped.").format(driveName, this.name); - let msg2 = _("Device %s can be turned off, if necessary.").format(unixDevice); - let btn = false; // Show the 'Retry' button? - try { - drive.stop_finish(res); - } catch(e) { - if (e.code == Gio.IOErrorEnum.BUSY) { - msg1 = _("Device %s is busy, please wait.".format(drive.get_name())); - msg2 = _("Do not disconnect or data loss may occur."); - - this._sendNotification(msg1, msg2, false, true); - this.busyWaitId = Mainloop.timeout_add_seconds(2, ()=>this._tryRemove()); - return; - } - btn = true; - msg1 = _("Unable to stop the drive %s (%s)").format(drive.get_name(), this.name); - msg2 = e.message; - } - if (DEBUG) global.log(msg1 + ": " + msg2); - this._sendNotification(msg1, msg2, btn); - }, - - _ejectFinish: function(source, res, is_drive) { - if (DEBUG) global.log("PlacesManager: **_ejectFinish**"); - let msg1; - let msg2 = null; - let btn = false; - - if (is_drive) { - let driveName = source.get_name(); // Ex: USB Flash Drive - let unixDevice = source.get_identifier('unix-device'); // Ex: /dev/sdc - msg1 = _("%s (%s) can be safely unplugged.").format(driveName, this.name); - msg2 = _("Device %s can be removed.").format(unixDevice); - } else { - msg1 = _("%s (%s) has just been ejected.").format(source.get_name(), this.name); - } - try { - source.eject_with_operation_finish(res); - } catch(e) { - if (e.code == Gio.IOErrorEnum.BUSY) { - msg1 = _("Device %s is busy, please wait.".format(source.get_name())); - msg2 = _("Do not remove or data loss may occur."); - - this._sendNotification(msg1, msg2, false, true); - this.busyWaitId = Mainloop.timeout_add_seconds(2, ()=>this._tryRemove()); - return; - } - btn = true; - msg1 = _("Unable to eject the drive %s (%s)").format(source.get_name(), this.name); - msg2 = e.message; - } - if (DEBUG) global.log(msg1 + ": " + msg2); - this._sendNotification(msg1, msg2, btn); - }, - - _removeFinish: function(o, res, data) { - if (DEBUG) global.log("PlacesManager: **_removeFinish**"); - let msg1 = _("Successfully unmounted %s (%s)").format(o.get_name(), this.name); - let msg2 = null; - let btn = false; - - // 'this._mount.can_eject()' seems to be ever false. Thus, only the 'else' part will be used. - // If no issues are reported, these 19 lines of code commented below can be deleted. - //~ if (this._mount.can_eject()) { - //~ msg1 = _("%s (%s) can be safely unplugged").format(o.get_name(), this.name); - //~ msg2 = _("Device can be removed"); - //~ try { - //~ this._mount.eject_with_operation_finish(res); - //~ } catch(e) { - //~ btn = true; - //~ msg1 = _("Failed to eject %s (%s)").format(o.get_name(), this.name); - //~ msg2 = e.message; - //~ } - //~ } else { - //~ try { - //~ this._mount.unmount_with_operation_finish(res); - //~ } catch(e) { - //~ btn = true; - //~ msg1 = _("Failed to unmount %s (%s)").format(o.get_name(), this.name); - //~ msg2 = e.message; - //~ } - //~ } - // <--Beginning of the code replacing the 19 lines above: - try { - this._mount.unmount_with_operation_finish(res); - } catch(e) { - if (e.code == Gio.IOErrorEnum.BUSY) { - msg1 = _("Device %s is busy, please wait.".format(o.get_name())); - msg2 = _("Do not disconnect or data loss may occur."); - - this._sendNotification(msg1, msg2, false, true); - this.busyWaitId = Mainloop.timeout_add_seconds(2, ()=>this._tryRemove()); - return; - } - btn = true; - msg1 = _("Failed to unmount %s (%s)").format(o.get_name(), this.name); - msg2 = e.message; - } - // End of this code.--> - - if (DEBUG) global.log(msg1 + ": " + msg2); - this._sendNotification(msg1, msg2, btn); } }; diff --git a/src/cinnamon-mount-operation.c b/src/cinnamon-mount-operation.c new file mode 100644 index 0000000000..d7096886eb --- /dev/null +++ b/src/cinnamon-mount-operation.c @@ -0,0 +1,189 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ +/* + * Copyright (C) 2011 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + * + * Author: Cosimo Cecchi + * + */ + +#include "cinnamon-mount-operation.h" + +/* This is a dummy class; we would like to be able to subclass the + * object from JS but we can't yet; the default GMountOperation impl + * automatically calls g_mount_operation_reply(UNHANDLED) after an idle, + * in interactive methods. We want to handle the reply ourselves + * instead, so we just override the default methods with empty ones, + * except for ask-password, as we don't want to handle that. + * + * Also, we need to workaround the fact that gjs doesn't support type + * annotations for signals yet (so we can't effectively forward e.g. + * the GPid array to JS). + * See https://bugzilla.gnome.org/show_bug.cgi?id=645978 + */ + +enum { + SHOW_PROCESSES_2, + NUM_SIGNALS +}; + +static guint signals[NUM_SIGNALS] = { 0, }; + +typedef struct _CinnamonMountOperationPrivate CinnamonMountOperationPrivate; + +struct _CinnamonMountOperation +{ + GMountOperation parent_instance; + + CinnamonMountOperationPrivate *priv; +}; + +struct _CinnamonMountOperationPrivate { + GArray *pids; + gchar **choices; + gchar *message; +}; + +G_DEFINE_TYPE_WITH_PRIVATE (CinnamonMountOperation, cinnamon_mount_operation, G_TYPE_MOUNT_OPERATION); + +static void +cinnamon_mount_operation_init (CinnamonMountOperation *self) +{ + self->priv = cinnamon_mount_operation_get_instance_private (self); +} + +static void +cinnamon_mount_operation_ask_password (GMountOperation *op, + const char *message, + const char *default_user, + const char *default_domain, + GAskPasswordFlags flags) +{ + /* do nothing */ +} + +static void +cinnamon_mount_operation_ask_question (GMountOperation *op, + const char *message, + const char *choices[]) +{ + /* do nothing */ +} + +static void +cinnamon_mount_operation_show_processes (GMountOperation *operation, + const gchar *message, + GArray *processes, + const gchar *choices[]) +{ + CinnamonMountOperation *self = CINNAMON_MOUNT_OPERATION (operation); + + if (self->priv->pids != NULL) + { + g_array_unref (self->priv->pids); + self->priv->pids = NULL; + } + + g_free (self->priv->message); + g_strfreev (self->priv->choices); + + /* save the parameters */ + self->priv->pids = g_array_ref (processes); + self->priv->choices = g_strdupv ((gchar **) choices); + self->priv->message = g_strdup (message); + + g_signal_emit (self, signals[SHOW_PROCESSES_2], 0); +} + +static void +cinnamon_mount_operation_finalize (GObject *obj) +{ + CinnamonMountOperation *self = CINNAMON_MOUNT_OPERATION (obj); + + g_strfreev (self->priv->choices); + g_free (self->priv->message); + + if (self->priv->pids != NULL) + { + g_array_unref (self->priv->pids); + self->priv->pids = NULL; + } + + G_OBJECT_CLASS (cinnamon_mount_operation_parent_class)->finalize (obj); +} + +static void +cinnamon_mount_operation_class_init (CinnamonMountOperationClass *klass) +{ + GMountOperationClass *mclass; + GObjectClass *oclass; + + mclass = G_MOUNT_OPERATION_CLASS (klass); + mclass->show_processes = cinnamon_mount_operation_show_processes; + mclass->ask_question = cinnamon_mount_operation_ask_question; + mclass->ask_password = cinnamon_mount_operation_ask_password; + + oclass = G_OBJECT_CLASS (klass); + oclass->finalize = cinnamon_mount_operation_finalize; + + signals[SHOW_PROCESSES_2] = + g_signal_new ("show-processes-2", + G_TYPE_FROM_CLASS (klass), + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, NULL, + G_TYPE_NONE, 0); +} + +GMountOperation * +cinnamon_mount_operation_new (void) +{ + return g_object_new (CINNAMON_TYPE_MOUNT_OPERATION, NULL); +} + +/** + * cinnamon_mount_operation_get_show_processes_pids: + * @self: a #CinnamonMountOperation + * + * Returns: (transfer full) (element-type GPid): a #GArray + */ +GArray * +cinnamon_mount_operation_get_show_processes_pids (CinnamonMountOperation *self) +{ + return g_array_ref (self->priv->pids); +} + +/** + * cinnamon_mount_operation_get_show_processes_choices: + * @self: a #CinnamonMountOperation + * + * Returns: (transfer full): + */ +gchar ** +cinnamon_mount_operation_get_show_processes_choices (CinnamonMountOperation *self) +{ + return g_strdupv (self->priv->choices); +} + +/** + * cinnamon_mount_operation_get_show_processes_message: + * @self: a #CinnamonMountOperation + * + * Returns: (transfer full): + */ +gchar * +cinnamon_mount_operation_get_show_processes_message (CinnamonMountOperation *self) +{ + return g_strdup (self->priv->message); +} diff --git a/src/cinnamon-mount-operation.h b/src/cinnamon-mount-operation.h new file mode 100644 index 0000000000..ed79929cd6 --- /dev/null +++ b/src/cinnamon-mount-operation.h @@ -0,0 +1,41 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ +/* + * Copyright (C) 2011 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + * + * Author: Cosimo Cecchi + * + */ + +#ifndef __CINNAMON_MOUNT_OPERATION_H__ +#define __CINNAMON_MOUNT_OPERATION_H__ + +#include + +G_BEGIN_DECLS + +#define CINNAMON_TYPE_MOUNT_OPERATION (cinnamon_mount_operation_get_type ()) +G_DECLARE_FINAL_TYPE (CinnamonMountOperation, cinnamon_mount_operation, + CINNAMON, MOUNT_OPERATION, GMountOperation) + +GMountOperation *cinnamon_mount_operation_new (void); + +GArray * cinnamon_mount_operation_get_show_processes_pids (CinnamonMountOperation *self); +gchar ** cinnamon_mount_operation_get_show_processes_choices (CinnamonMountOperation *self); +gchar * cinnamon_mount_operation_get_show_processes_message (CinnamonMountOperation *self); + +G_END_DECLS + +#endif /* __CINNAMON_MOUNT_OPERATION_H__ */ diff --git a/src/meson.build b/src/meson.build index 548b94f8dc..ed2504d6ce 100644 --- a/src/meson.build +++ b/src/meson.build @@ -19,6 +19,7 @@ cinnamon_headers = [ 'cinnamon-glsl-effect.h', 'cinnamon-gtk-embed.h', 'cinnamon-global.h', + 'cinnamon-mount-operation.h', 'cinnamon-perf-log.h', 'cinnamon-screen.h', 'cinnamon-screenshot.h', @@ -53,6 +54,7 @@ cinnamon_sources = [ 'cinnamon-global.c', 'cinnamon-keyring-prompt.c', 'cinnamon-keyring-prompt.h', + 'cinnamon-mount-operation.c', 'cinnamon-perf-log.c', 'cinnamon-polkit-authentication-agent.c', 'cinnamon-polkit-authentication-agent.h', From a88ad023519ae605678fbfed17c333ea9fe5450c Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Thu, 16 Oct 2025 14:50:15 -0400 Subject: [PATCH 2/7] Implement automount/autorun detection and interaction in Cinnamon. This aims to replace cinnamon-settings-daemon's automount manager. It was originally part of Cinnamon but mostly removed early on when Cinnamon was forked, and we've relied on csd-automount. With the implementation of CinnamonMountOperation for handling unmount operations, we can bring in the autorun dialog as well. --- js/ui/automountManager.js | 272 +++++++++++++++++ js/ui/autorunManager.js | 313 ++++++++++++++++++++ js/ui/main.js | 7 + src/hotplug-sniffer/cinnamon-mime-sniffer.c | 104 +++---- src/hotplug-sniffer/cinnamon-mime-sniffer.h | 31 +- src/hotplug-sniffer/hotplug-sniffer.c | 19 +- 6 files changed, 646 insertions(+), 100 deletions(-) create mode 100644 js/ui/automountManager.js create mode 100644 js/ui/autorunManager.js diff --git a/js/ui/automountManager.js b/js/ui/automountManager.js new file mode 100644 index 0000000000..a8b93c1e45 --- /dev/null +++ b/js/ui/automountManager.js @@ -0,0 +1,272 @@ +// -*- 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 CinnamonMountOperation = imports.ui.cinnamonMountOperation; + +var GNOME_SESSION_AUTOMOUNT_INHIBIT = 16; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.cinnamon.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(); + + GnomeSession.SessionManager((proxy, error) => { + if (error) + return; + + this._session = proxy; + this.actor.show(); + this.updateStatus(); + + this._session.connectSignal( + "InhibitorAdded", + this._InhibitorsChanged.bind(this) + ); + + this._session.connectSignal( + "InhibitorRemoved", + this._InhibitorsChanged.bind(this) + ); + }); + + this._inhibited = false; + + this._volumeMonitor = Gio.VolumeMonitor.get(); + this.enable(); + } + + 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, '[cinnamon] 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) { + global.log("check and mount"); + 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 CinnamonMountOperation.CinnamonMountOperation(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) { + global.log("on volume mounted"); + 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 CinnamonMountOperation.CinnamonMountOperation(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, '[cinnamon] volume.allowAutorun'); + } +}; diff --git a/js/ui/autorunManager.js b/js/ui/autorunManager.js new file mode 100644 index 0000000000..d35a212596 --- /dev/null +++ b/js/ui/autorunManager.js @@ -0,0 +1,313 @@ +// -*- 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 hotplugSnifferIface = +' \ + \ + \ + \ + \ + \ + \ + \ + \ +'; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.cinnamon.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()); + } catch (e) { + log(`Unable to launch the app ${app.get_name()}: ${e}`); + } + + return retval; +} + +const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(hotplugSnifferIface); +function HotplugSniffer() { + return new HotplugSnifferProxy(Gio.DBus.session, + 'org.Cinnamon.HotplugSniffer', + '/org/Cinnamon/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); + this.enable(); + } + + 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 = class extends MessageTray.Source { + constructor(manager, mount, apps) { + super(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.notify(this._notification); + } + + createNotificationIcon () { + return new St.Icon({ + gicon: this.mount.get_symbolic_icon(), + icon_type: St.IconType.SYMBOLIC, + icon_size: this.ICON_SIZE + }); + } +}; + +var AutorunNotification = class extends MessageTray.Notification { + constructor(manager, source) { + super(source, source.title); + + this._manager = manager; + this._mount = source.mount; + + this.source.apps.forEach(app => { + this.addButton(app.get_id(), _("Open with %s").format(app.get_name())); + }); + + this.connect("action-invoked", (notification, id) => { + let app = this.source.apps.find(a => a.get_id() == id); + if (app) + startAppForMount(app, this._mount); + }); + } + + activate() { + super.activate(); + + let app = Gio.app_info_get_default_for_type('inode/directory', false); + startAppForMount(app, this._mount); + } +}; diff --git a/js/ui/main.js b/js/ui/main.js index 816fa06083..fd72d77fc8 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -91,6 +91,8 @@ const GObject = imports.gi.GObject; const XApp = imports.gi.XApp; const PointerTracker = imports.misc.pointerTracker; +const AutomountManager = imports.ui.automountManager; +const AutorunManager = imports.ui.autorunManager; const AudioDeviceSelection = imports.ui.audioDeviceSelection; const SoundManager = imports.ui.soundManager; const BackgroundManager = imports.ui.backgroundManager; @@ -167,6 +169,8 @@ var screenRecorder = null; var cinnamonAudioSelectionDBusService = null; var cinnamonDBusService = null; var cinnamonMountOpDBusService = null; +var automountManager = null; +var autorunManager = null; var screenshotService = null; var modalCount = 0; var modalActorFocusStack = []; @@ -348,6 +352,9 @@ function start() { cinnamonAudioSelectionDBusService = new AudioDeviceSelection.AudioDeviceSelectionDBus(); cinnamonDBusService = new CinnamonDBus.CinnamonDBus(); cinnamonMountOpDBusService = new CinnamonMountOperation.CinnamonMountOpHandler(); + automountManager = new AutomountManager.AutomountManager(); + autorunManager = new AutorunManager.AutorunManager(); + setRunState(RunState.STARTUP); screenshotService = new Screenshot.ScreenshotService(); diff --git a/src/hotplug-sniffer/cinnamon-mime-sniffer.c b/src/hotplug-sniffer/cinnamon-mime-sniffer.c index aaa50066c8..c17ca210eb 100644 --- a/src/hotplug-sniffer/cinnamon-mime-sniffer.c +++ b/src/hotplug-sniffer/cinnamon-mime-sniffer.c @@ -14,9 +14,7 @@ * General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street - Suite 500, Boston, MA - * 02110-1335, USA. + * along with this program; if not, see . * * Author: Cosimo Cecchi * @@ -45,8 +43,6 @@ #define DIRECTORY_LOAD_ITEMS_PER_CALLBACK 100 #define HIGH_SCORE_RATIO 0.10 -G_DEFINE_TYPE (CinnamonMimeSniffer, cinnamon_mime_sniffer, G_TYPE_OBJECT); - enum { PROP_FILE = 1, NUM_PROPERTIES @@ -74,16 +70,26 @@ typedef struct { gint total_items; } DeepCountState; +typedef struct _CinnamonMimeSnifferPrivate CinnamonMimeSnifferPrivate; + +struct _CinnamonMimeSniffer +{ + GObject parent_instance; + + CinnamonMimeSnifferPrivate *priv; +}; + struct _CinnamonMimeSnifferPrivate { GFile *file; GCancellable *cancellable; guint watchdog_id; - GSimpleAsyncResult *async_result; - gchar **sniffed_mime; + GTask *task; }; +G_DEFINE_TYPE_WITH_PRIVATE (CinnamonMimeSniffer, cinnamon_mime_sniffer, G_TYPE_OBJECT); + static void deep_count_load (DeepCountState *state, GFile *file); @@ -181,6 +187,7 @@ prepare_async_result (DeepCountState *state) GArray *results; GPtrArray *sniffed_mime; SniffedResult result; + char **mimes; sniffed_mime = g_ptr_array_new (); results = g_array_new (TRUE, TRUE, sizeof (SniffedResult)); @@ -222,16 +229,16 @@ prepare_async_result (DeepCountState *state) out: g_ptr_array_add (sniffed_mime, NULL); - self->priv->sniffed_mime = (gchar **) g_ptr_array_free (sniffed_mime, FALSE); + mimes = (gchar **) g_ptr_array_free (sniffed_mime, FALSE); g_array_free (results, TRUE); - g_simple_async_result_complete_in_idle (self->priv->async_result); + g_task_return_pointer (self->priv->task, mimes, (GDestroyNotify)g_strfreev); } /* adapted from nautilus/libnautilus-private/nautilus-directory-async.c */ static void deep_count_one (DeepCountState *state, - GFileInfo *info) + GFileInfo *info) { GFile *subdir; const char *content_type; @@ -242,11 +249,13 @@ deep_count_one (DeepCountState *state, subdir = g_file_get_child (state->file, g_file_info_get_name (info)); state->deep_count_subdirectories = g_list_append (state->deep_count_subdirectories, subdir); - } + } else { content_type = g_file_info_get_content_type (info); - add_content_type_to_cache (state, content_type); + + if (content_type) + add_content_type_to_cache (state, content_type); } } @@ -297,8 +306,8 @@ deep_count_next_dir (DeepCountState *state) static void deep_count_more_files_callback (GObject *source_object, - GAsyncResult *res, - gpointer user_data) + GAsyncResult *res, + gpointer user_data) { DeepCountState *state; GList *files, *l; @@ -311,10 +320,10 @@ deep_count_more_files_callback (GObject *source_object, deep_count_finish (state); return; } - + files = g_file_enumerator_next_files_finish (state->enumerator, res, NULL); - + for (l = files; l != NULL; l = l->next) { info = l->data; @@ -345,8 +354,8 @@ deep_count_more_files_callback (GObject *source_object, static void deep_count_callback (GObject *source_object, - GAsyncResult *res, - gpointer user_data) + GAsyncResult *res, + gpointer user_data) { DeepCountState *state; GFileEnumerator *enumerator; @@ -361,7 +370,7 @@ deep_count_callback (GObject *source_object, enumerator = g_file_enumerate_children_finish (G_FILE (source_object), res, NULL); - + if (enumerator == NULL) { deep_count_next_dir (state); @@ -418,21 +427,18 @@ query_info_async_ready_cb (GObject *source, if (error != NULL) { - g_simple_async_result_take_error (self->priv->async_result, - error); - g_simple_async_result_complete_in_idle (self->priv->async_result); + g_task_return_error (self->priv->task, error); return; } if (g_file_info_get_file_type (info) != G_FILE_TYPE_DIRECTORY) { - g_simple_async_result_set_error (self->priv->async_result, - G_IO_ERROR, - G_IO_ERROR_NOT_DIRECTORY, - "Not a directory"); - g_simple_async_result_complete_in_idle (self->priv->async_result); - g_object_unref(info); + g_task_return_new_error (self->priv->task, + G_IO_ERROR, + G_IO_ERROR_NOT_DIRECTORY, + "Not a directory"); + return; } @@ -477,27 +483,13 @@ cinnamon_mime_sniffer_dispose (GObject *object) g_clear_object (&self->priv->file); g_clear_object (&self->priv->cancellable); - g_clear_object (&self->priv->async_result); + g_clear_object (&self->priv->task); - if (self->priv->watchdog_id != 0) - { - g_source_remove (self->priv->watchdog_id); - self->priv->watchdog_id = 0; - } + g_clear_handle_id (&self->priv->watchdog_id, g_source_remove); G_OBJECT_CLASS (cinnamon_mime_sniffer_parent_class)->dispose (object); } -static void -cinnamon_mime_sniffer_finalize (GObject *object) -{ - CinnamonMimeSniffer *self = CINNAMON_MIME_SNIFFER (object); - - g_strfreev (self->priv->sniffed_mime); - - G_OBJECT_CLASS (cinnamon_mime_sniffer_parent_class)->finalize (object); -} - static void cinnamon_mime_sniffer_get_property (GObject *object, guint prop_id, @@ -541,7 +533,6 @@ cinnamon_mime_sniffer_class_init (CinnamonMimeSnifferClass *klass) oclass = G_OBJECT_CLASS (klass); oclass->dispose = cinnamon_mime_sniffer_dispose; - oclass->finalize = cinnamon_mime_sniffer_finalize; oclass->get_property = cinnamon_mime_sniffer_get_property; oclass->set_property = cinnamon_mime_sniffer_set_property; @@ -550,19 +541,15 @@ cinnamon_mime_sniffer_class_init (CinnamonMimeSnifferClass *klass) "File", "The loaded file", G_TYPE_FILE, - G_PARAM_READWRITE); + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS); - g_type_class_add_private (klass, sizeof (CinnamonMimeSnifferPrivate)); g_object_class_install_properties (oclass, NUM_PROPERTIES, properties); } static void cinnamon_mime_sniffer_init (CinnamonMimeSniffer *self) { - self->priv = - G_TYPE_INSTANCE_GET_PRIVATE (self, - CINNAMON_TYPE_MIME_SNIFFER, - CinnamonMimeSnifferPrivate); + self->priv = cinnamon_mime_sniffer_get_instance_private (self); init_mimetypes (); } @@ -580,18 +567,16 @@ cinnamon_mime_sniffer_sniff_async (CinnamonMimeSniffer *self, gpointer user_data) { g_assert (self->priv->watchdog_id == 0); - g_assert (self->priv->async_result == NULL); - - self->priv->async_result = - g_simple_async_result_new (G_OBJECT (self), - callback, user_data, - cinnamon_mime_sniffer_sniff_finish); + g_assert (self->priv->task == NULL); self->priv->cancellable = g_cancellable_new (); + self->priv->task = g_task_new (self, self->priv->cancellable, + callback, user_data); self->priv->watchdog_id = g_timeout_add (WATCHDOG_TIMEOUT, watchdog_timeout_reached_cb, self); + g_source_set_name_by_id (self->priv->watchdog_id, "[gnome-shell] watchdog_timeout_reached_cb"); start_loading_file (self); } @@ -601,8 +586,5 @@ cinnamon_mime_sniffer_sniff_finish (CinnamonMimeSniffer *self, GAsyncResult *res, GError **error) { - if (g_simple_async_result_propagate_error (self->priv->async_result, error)) - return NULL; - - return g_strdupv (self->priv->sniffed_mime); + return g_task_propagate_pointer (self->priv->task, error); } diff --git a/src/hotplug-sniffer/cinnamon-mime-sniffer.h b/src/hotplug-sniffer/cinnamon-mime-sniffer.h index d6e47cd074..dcfcf934a1 100644 --- a/src/hotplug-sniffer/cinnamon-mime-sniffer.h +++ b/src/hotplug-sniffer/cinnamon-mime-sniffer.h @@ -13,9 +13,7 @@ * General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street - Suite 500, Boston, MA - * 02110-1335, USA. + * along with this program; if not, see . * * Author: Cosimo Cecchi * @@ -29,30 +27,9 @@ G_BEGIN_DECLS -#define CINNAMON_TYPE_MIME_SNIFFER (cinnamon_mime_sniffer_get_type ()) -#define CINNAMON_MIME_SNIFFER(obj) (G_TYPE_CHECK_INSTANCE_CAST ((obj), CINNAMON_TYPE_MIME_SNIFFER, CinnamonMimeSniffer)) -#define CINNAMON_IS_MIME_SNIFFER(obj) (G_TYPE_CHECK_INSTANCE_TYPE ((obj), CINNAMON_TYPE_MIME_SNIFFER)) -#define CINNAMON_MIME_SNIFFER_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), CINNAMON_TYPE_MIME_SNIFFER, CinnamonMimeSnifferClass)) -#define CINNAMON_IS_MIME_SNIFFER_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), CINNAMON_TYPE_MIME_SNIFFER)) -#define CINNAMON_MIME_SNIFFER_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), CINNAMON_TYPE_MIME_SNIFFER, CinnamonMimeSnifferClass)) - -typedef struct _CinnamonMimeSniffer CinnamonMimeSniffer; -typedef struct _CinnamonMimeSnifferPrivate CinnamonMimeSnifferPrivate; -typedef struct _CinnamonMimeSnifferClass CinnamonMimeSnifferClass; - -struct _CinnamonMimeSniffer -{ - GObject parent_instance; - - CinnamonMimeSnifferPrivate *priv; -}; - -struct _CinnamonMimeSnifferClass -{ - GObjectClass parent_class; -}; - -GType cinnamon_mime_sniffer_get_type (void) G_GNUC_CONST; +#define CINNAMON_TYPE_MIME_SNIFFER (cinnamon_mime_sniffer_get_type ()) +G_DECLARE_FINAL_TYPE (CinnamonMimeSniffer, cinnamon_mime_sniffer, + CINNAMON, MIME_SNIFFER, GObject) CinnamonMimeSniffer *cinnamon_mime_sniffer_new (GFile *file); diff --git a/src/hotplug-sniffer/hotplug-sniffer.c b/src/hotplug-sniffer/hotplug-sniffer.c index 343d2e9ca4..6682db84fc 100644 --- a/src/hotplug-sniffer/hotplug-sniffer.c +++ b/src/hotplug-sniffer/hotplug-sniffer.c @@ -13,9 +13,7 @@ * General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street - Suite 500, Boston, MA - * 02110-1335, USA. + * along with this program; if not, see . * * Authors: David Zeuthen * Cosimo Cecchi @@ -62,11 +60,7 @@ ensure_autoquit_off (void) if (g_getenv ("HOTPLUG_SNIFFER_PERSIST") != NULL) return; - if (autoquit_id != 0) - { - g_source_remove (autoquit_id); - autoquit_id = 0; - } + g_clear_handle_id (&autoquit_id, g_source_remove); } static void @@ -78,6 +72,7 @@ ensure_autoquit_on (void) autoquit_id = g_timeout_add_seconds (AUTOQUIT_TIMEOUT, autoquit_timeout_cb, NULL); + g_source_set_name_by_id (autoquit_id, "[cinnamon] autoquit_timeout_cb"); } typedef struct { @@ -91,7 +86,7 @@ invocation_data_new (GVariant *params, { InvocationData *ret; - ret = g_slice_new0 (InvocationData); + ret = g_new0 (InvocationData, 1); ret->parameters = g_variant_ref (params); ret->invocation = g_object_ref (invocation); @@ -104,7 +99,7 @@ invocation_data_free (InvocationData *data) g_variant_unref (data->parameters); g_clear_object (&data->invocation); - g_slice_free (InvocationData, data); + g_free (data); } static void @@ -128,9 +123,9 @@ sniff_async_ready_cb (GObject *source, g_dbus_method_invocation_return_value (data->invocation, g_variant_new ("(^as)", types)); + g_strfreev (types); out: - g_strfreev (types); invocation_data_free (data); ensure_autoquit_on (); } @@ -267,7 +262,7 @@ main (int argc, /* ---------------------------------------------------------------------------------------------------- */ -static void +static void __attribute__((format(printf, 1, 0))) print_debug (const gchar *format, ...) { g_autofree char *s = NULL; From 172b24a032ce9dda336cf7041ae64b9754d75b56 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Thu, 12 Mar 2026 14:00:19 -0400 Subject: [PATCH 3/7] autorun: Use a dialog, not a notification. - Improve the Dialog list widgets to allow selection, proper pseudo-classes for highlighting. - Use existing login manager for lock/unlock listeners (and make our screensaver set the locked hint finally). --- .../theme/cinnamon-sass/widgets/_dialogs.scss | 6 + js/misc/loginManager.js | 20 +- js/ui/automountManager.js | 107 ++--- js/ui/autorunManager.js | 365 +++++++++++++----- js/ui/cinnamonMountOperation.js | 9 - js/ui/dialog.js | 64 ++- js/ui/screensaver/screenShield.js | 8 +- 7 files changed, 399 insertions(+), 180 deletions(-) diff --git a/data/theme/cinnamon-sass/widgets/_dialogs.scss b/data/theme/cinnamon-sass/widgets/_dialogs.scss index 3f871e54ed..5d1866c52d 100644 --- a/data/theme/cinnamon-sass/widgets/_dialogs.scss +++ b/data/theme/cinnamon-sass/widgets/_dialogs.scss @@ -43,6 +43,12 @@ .dialog-list-item { spacing: 1em; + border-radius: $base_border_radius; + padding: $base_padding $base_padding * 2; + transition-duration: 100ms; + + &:hover { background-color: $light_bg_color; } + &:selected { background-color: $accent_bg_color; } .dialog-list-item-title { font-weight: bold; } .dialog-list-item-description { diff --git a/js/misc/loginManager.js b/js/misc/loginManager.js index 921f0db652..afd71c8de2 100644 --- a/js/misc/loginManager.js +++ b/js/misc/loginManager.js @@ -78,6 +78,7 @@ var LoginManagerSystemd = class { constructor() { this._managerProxy = null; this._sessionProxy = null; + this.sessionIsActive = true; this.isLocked = false; this._initSession(); @@ -150,25 +151,26 @@ var LoginManagerSystemd = class { this._sessionProxy.connectSignal('Lock', () => { _log('LoginManager: Received Lock signal from logind, emitting lock'); + this.isLocked = true; this.emit('lock'); }); this._sessionProxy.connectSignal('Unlock', () => { _log('LoginManager: Received Unlock signal from logind, emitting unlock'); + this.isLocked = false; this.emit('unlock'); }); this._sessionProxy.connect('g-properties-changed', (proxy, changed, invalidated) => { if ('Active' in changed.deep_unpack()) { let active = this._sessionProxy.Active; + this.sessionIsActive = active; _log(`LoginManager: Session Active property changed: ${active}`); - if (active) { - _log('LoginManager: Session became active, emitting active'); - this.emit('active'); - } + this.emit('active-changed', active); } }); + this.sessionIsActive = this._sessionProxy.Active; this.emit('session-ready'); } catch (e) { global.logError('LoginManager: Failed to connect to logind session: ' + e.message); @@ -229,6 +231,8 @@ var LoginManagerConsoleKit = class { constructor() { this._managerProxy = null; this._sessionProxy = null; + this.sessionIsActive = true; + this.isLocked = false; this._initSession(); } @@ -273,20 +277,20 @@ var LoginManagerConsoleKit = class { this._sessionProxy.connectSignal('Lock', () => { _log('LoginManager: Received Lock signal from ConsoleKit, emitting lock'); + this.isLocked = true; this.emit('lock'); }); this._sessionProxy.connectSignal('Unlock', () => { _log('LoginManager: Received Unlock signal from ConsoleKit, emitting unlock'); + this.isLocked = false; this.emit('unlock'); }); this._sessionProxy.connectSignal('ActiveChanged', (proxy, sender, [active]) => { + this.sessionIsActive = active; _log(`LoginManager: ConsoleKit ActiveChanged: ${active}`); - if (active) { - _log('LoginManager: Session became active, emitting active'); - this.emit('active'); - } + this.emit('active-changed', active); }); this.emit('session-ready'); diff --git a/js/ui/automountManager.js b/js/ui/automountManager.js index a8b93c1e45..b29ef5ca0d 100644 --- a/js/ui/automountManager.js +++ b/js/ui/automountManager.js @@ -4,12 +4,10 @@ const { Gio, GLib } = imports.gi; const Params = imports.misc.params; -const GnomeSession = imports.misc.gnomeSession; +const LoginManager = imports.misc.loginManager; const Main = imports.ui.main; const CinnamonMountOperation = imports.ui.cinnamonMountOperation; -var GNOME_SESSION_AUTOMOUNT_INHIBIT = 16; - // GSettings keys const SETTINGS_SCHEMA = 'org.cinnamon.desktop.media-handling'; const SETTING_ENABLE_AUTOMOUNT = 'automount'; @@ -20,28 +18,16 @@ var AutomountManager = class { constructor() { this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); this._activeOperations = new Map(); - - GnomeSession.SessionManager((proxy, error) => { - if (error) - return; - - this._session = proxy; - this.actor.show(); - this.updateStatus(); - - this._session.connectSignal( - "InhibitorAdded", - this._InhibitorsChanged.bind(this) - ); - - this._session.connectSignal( - "InhibitorRemoved", - this._InhibitorsChanged.bind(this) - ); + this._volumeQueue = []; + + this._loginManager = LoginManager.getLoginManager(); + this._loginManager.connect('lock', () => this._onScreenLocked()); + this._loginManager.connect('unlock', () => this._onScreenUnlocked()); + this._loginManager.connect('active-changed', (lm, active) => { + if (active) + this._drainVolumeQueue(); }); - this._inhibited = false; - this._volumeMonitor = Gio.VolumeMonitor.get(); this.enable(); } @@ -67,12 +53,21 @@ var AutomountManager = class { } } - async _InhibitorsChanged(_object, _senderName, [_inhibitor]) { - try { - const [inhibited] = - await this._session.IsInhibitedAsync(GNOME_SESSION_AUTOMOUNT_INHIBIT); - this._inhibited = inhibited; - } catch (e) {} + _onScreenLocked() { + // Volumes inserted while locked will be queued + } + + _onScreenUnlocked() { + this._drainVolumeQueue(); + } + + _drainVolumeQueue() { + while (this._volumeQueue.length > 0) { + let volume = this._volumeQueue.shift(); + this._checkAndMountVolume(volume, { + checkSession: false, + }); + } } _startupMountAll() { @@ -90,10 +85,8 @@ var AutomountManager = class { } _onDriveConnected() { - // if we're not in the current ConsoleKit session, - // or screensaver is active, don't play sounds - // if (!this._session.SessionIsActive) - // return; + if (!this._loginManager.sessionIsActive) + return; let player = global.display.get_sound_player(); player.play_from_theme('device-added-media', @@ -102,10 +95,8 @@ var AutomountManager = class { } _onDriveDisconnected() { - // if we're not in the current ConsoleKit session, - // or screensaver is active, don't play sounds - // if (!this._session.SessionIsActive) - // return; + if (!this._loginManager.sessionIsActive) + return; let player = global.display.get_sound_player(); player.play_from_theme('device-removed-media', @@ -114,13 +105,9 @@ var AutomountManager = class { } _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; + if (!this._loginManager.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) => { @@ -147,7 +134,6 @@ var AutomountManager = class { } _checkAndMountVolume(volume, params) { - global.log("check and mount"); params = Params.parse(params, { checkSession: true, useMountOp: true, @@ -155,26 +141,21 @@ var AutomountManager = class { }); if (params.checkSession) { - // if we're not in the current ConsoleKit session, - // don't attempt automount - // if (!this._session.SessionIsActive) - // return; - } + if (!this._loginManager.sessionIsActive) + return; - if (this._inhibited) - return; + if (this._loginManager.isLocked) { + this._volumeQueue.push(volume); + 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); @@ -201,23 +182,15 @@ var AutomountManager = class { } _onVolumeMounted(volume, res) { - global.log("on volume mounted"); 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 + if (e.message.includes('No key available with this passphrase') || + e.message.includes('No key available to unlock 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 { @@ -238,6 +211,8 @@ var AutomountManager = class { GLib.source_remove(volume._allowAutorunExpireId); delete volume._allowAutorunExpireId; } + + this._volumeQueue = this._volumeQueue.filter(v => v !== volume); } _reaskPassword(volume) { diff --git a/js/ui/autorunManager.js b/js/ui/autorunManager.js index d35a212596..0d532ffb08 100644 --- a/js/ui/autorunManager.js +++ b/js/ui/autorunManager.js @@ -3,9 +3,9 @@ const { Clutter, Gio, GObject, St } = imports.gi; -const GnomeSession = imports.misc.gnomeSession; -const Main = imports.ui.main; -const MessageTray = imports.ui.messageTray; +const CheckBox = imports.ui.checkBox; +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; Gio._promisify(Gio.Mount.prototype, 'guess_content_type'); @@ -27,6 +27,7 @@ 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'; +const SETTING_AUTOMOUNT_OPEN = 'automount-open'; var AutorunSetting = { RUN: 0, @@ -35,7 +36,12 @@ var AutorunSetting = { ASK: 3, }; -// misc utils +const AUTORUN_ITEM_APP = 0; +const AUTORUN_ITEM_OPEN_FOLDER = 1; +const AUTORUN_ITEM_DO_NOTHING = 2; + +var LIST_ITEM_ICON_SIZE = 24; + function shouldAutorunMount(mount) { let root = mount.get_root(); let volume = mount.get_volume(); @@ -43,6 +49,10 @@ function shouldAutorunMount(mount) { if (!volume || !volume.allowAutorun) return false; + // Consume the flag so subsequent mounts of the same volume + // (e.g. user manually mounting via Nemo) don't re-trigger autorun. + volume.allowAutorun = false; + if (root.is_native() && isMountRootHidden(root)) return false; @@ -52,14 +62,10 @@ function shouldAutorunMount(mount) { 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; @@ -68,19 +74,67 @@ function isMountNonLocal(mount) { } 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()); + return app.launch([root], global.create_app_launch_context()); } catch (e) { log(`Unable to launch the app ${app.get_name()}: ${e}`); } - return retval; + return false; +} + +function _getMediaGreeting(contentType) { + if (contentType === 'x-content/audio-cdda') + return _("You have just inserted an Audio CD."); + if (contentType === 'x-content/audio-dvd') + return _("You have just inserted an Audio DVD."); + if (contentType === 'x-content/video-dvd') + return _("You have just inserted a Video DVD."); + if (contentType === 'x-content/video-vcd') + return _("You have just inserted a Video CD."); + if (contentType === 'x-content/video-svcd') + return _("You have just inserted a Super Video CD."); + if (contentType === 'x-content/blank-cd') + return _("You have just inserted a blank CD."); + if (contentType === 'x-content/blank-dvd') + return _("You have just inserted a blank DVD."); + if (contentType === 'x-content/blank-bd') + return _("You have just inserted a blank Blu-Ray disc."); + if (contentType === 'x-content/blank-hddvd') + return _("You have just inserted a blank HD DVD."); + if (contentType === 'x-content/image-photocd') + return _("You have just inserted a Photo CD."); + if (contentType === 'x-content/image-picturecd') + return _("You have just inserted a Picture CD."); + if (contentType === 'x-content/image-dcf') + return _("You have just inserted a medium with digital photos."); + if (contentType === 'x-content/audio-player') + return _("You have just inserted a digital audio player."); + if (contentType && Gio.content_type_is_a(contentType, 'x-content/software')) + return _("You have just inserted a medium with software intended to be automatically started."); + + return _("You have just inserted a medium."); +} + +function _setAutorunPreferences(contentType, startApp, ignore, openFolder) { + let settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + + let startAppTypes = settings.get_strv(SETTING_START_APP).filter(t => t !== contentType); + if (startApp) + startAppTypes.push(contentType); + settings.set_strv(SETTING_START_APP, startAppTypes); + + let ignoreTypes = settings.get_strv(SETTING_IGNORE).filter(t => t !== contentType); + if (ignore) + ignoreTypes.push(contentType); + settings.set_strv(SETTING_IGNORE, ignoreTypes); + + let openFolderTypes = settings.get_strv(SETTING_OPEN_FOLDER).filter(t => t !== contentType); + if (openFolder) + openFolderTypes.push(contentType); + settings.set_strv(SETTING_OPEN_FOLDER, openFolderTypes); } const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(hotplugSnifferIface); @@ -114,7 +168,6 @@ var ContentTypeDiscoverer = class { } } - // we're not interested in win32 software content types here contentTypes = contentTypes.filter( type => type !== 'x-content/win32-software'); @@ -126,17 +179,14 @@ var ContentTypeDiscoverer = class { 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._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); this._dispatcher = new AutorunDispatcher(this); this.enable(); @@ -153,16 +203,29 @@ var AutorunManager = class { } async _onMountAdded(monitor, mount) { - // don't do anything if our session is not the currently - // active one - // if (!this._session.SessionIsActive) - // return; + if (!shouldAutorunMount(mount)) + return; const discoverer = new ContentTypeDiscoverer(); const [apps, contentTypes] = await discoverer.guessContentTypes(mount); + + log(`autorunManager: mount=${mount.get_name()} contentTypes=[${contentTypes}] apps=[${apps.map(a => a.get_name())}]`); + + if (apps.length === 0) { + if (this._settings.get_boolean(SETTING_AUTOMOUNT_OPEN)) + this._openFolderForMount(mount); + return; + } + this._dispatcher.addMount(mount, apps, contentTypes); } + _openFolderForMount(mount) { + let app = Gio.app_info_get_default_for_type('inode/directory', false); + if (app) + startAppForMount(app, mount); + } + _onMountRemoved(monitor, mount) { this._dispatcher.removeMount(mount); } @@ -171,7 +234,7 @@ var AutorunManager = class { var AutorunDispatcher = class { constructor(manager) { this._manager = manager; - this._sources = []; + this._dialogs = []; this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); } @@ -191,123 +254,241 @@ var AutorunDispatcher = class { 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; + _getDialogForMount(mount) { + return this._dialogs.find(d => d.mount === mount) ?? null; } - _addSource(mount, apps) { - // if we already have a source showing for this - // mount, return - if (this._getSourceForMount(mount)) + _showDialog(mount, apps, contentType) { + if (this._getDialogForMount(mount)) return; - // add a new source - this._sources.push(new AutorunSource(this._manager, mount, apps)); + let dialog = new AutorunDialog(mount, apps, contentType); + this._dialogs.push(dialog); + + dialog.connect('destroy', () => { + this._dialogs = this._dialogs.filter(d => d !== dialog); + }); + + dialog.open(); } 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 contentType = contentTypes.length > 0 ? contentTypes[0] : null; + let setting; - if (contentTypes.length > 0) - setting = this._getAutorunSettingForType(contentTypes[0]); + if (contentType) + setting = this._getAutorunSettingForType(contentType); 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 + if (setting === AutorunSetting.IGNORE) + return; 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) + if (setting === AutorunSetting.RUN && contentType) + app = Gio.app_info_get_default_for_type(contentType, 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); + this._showDialog(mount, apps, contentType); } removeMount(mount) { - let source = this._getSourceForMount(mount); - - // if we aren't tracking this mount, don't do anything - if (!source) + let dialog = this._getDialogForMount(mount); + if (!dialog) return; - // destroy the notification source - source.destroy(); + dialog.close(); } }; -var AutorunSource = class extends MessageTray.Source { - constructor(manager, mount, apps) { - super(mount.get_name()); +var AutorunDialog = GObject.registerClass( +class AutorunDialog extends ModalDialog.ModalDialog { + _init(mount, apps, contentType) { + super._init({ styleClass: 'autorun-dialog' }); - this._manager = manager; this.mount = mount; - this.apps = apps; + this._apps = apps; + this._contentType = contentType; - this._notification = new AutorunNotification(this._manager, this); + let mountName = mount.get_name(); - // add ourselves as a source, and popup the notification - Main.messageTray.add(this); - this.notify(this._notification); + let greeting = _getMediaGreeting(contentType); + let title = `${greeting} ${_("Choose what application to launch.")}`; + + let contentDescription = contentType + ? Gio.content_type_get_description(contentType) + : null; + + let description; + if (contentDescription) { + description = _("Select how to open \"%s\" and whether to perform this action in the future for other media of type \"%s\".") + .format(mountName, contentDescription); + } else { + description = _("Select how to open \"%s\".").format(mountName); + } + + this._content = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_child(this._content); + + this._appSection = new Dialog.ListSection({ selectable: true }); + this.contentLayout.add_child(this._appSection); + + this._populateAppList(); + this._appSection.selectIndex(0); + + this._alwaysCheckBox = new CheckBox.CheckBox(_("Always perform this action")); + this.contentLayout.add_child(this._alwaysCheckBox); + + let canEject = mount.can_eject(); + let ejectLabel = canEject ? _("Eject") : _("Unmount"); + + this.setButtons([ + { + label: _("Cancel"), + action: () => this.close(), + key: Clutter.KEY_Escape, + }, + { + label: ejectLabel, + action: () => this._onEject(), + }, + { + label: _("OK"), + action: () => this._onOk(), + default: true, + }, + ]); + + this._unmountedId = mount.connect('unmounted', () => this.close()); } - createNotificationIcon () { - return new St.Icon({ - gicon: this.mount.get_symbolic_icon(), - icon_type: St.IconType.SYMBOLIC, - icon_size: this.ICON_SIZE + _populateAppList() { + this._apps.forEach((app, idx) => { + let icon = app.get_icon(); + let iconActor = icon + ? new St.Icon({ gicon: icon, icon_size: LIST_ITEM_ICON_SIZE }) + : new St.Icon({ icon_name: 'application-x-executable', icon_size: LIST_ITEM_ICON_SIZE }); + + let item = new Dialog.ListSectionItem({ + icon_actor: iconActor, + title: app.get_name(), + }); + item._autorunType = AUTORUN_ITEM_APP; + item._appIndex = idx; + this._appSection.addItem(item); }); + + let openFolderItem = new Dialog.ListSectionItem({ + icon_actor: new St.Icon({ icon_name: 'folder-open', icon_size: LIST_ITEM_ICON_SIZE }), + title: _("Open Folder"), + }); + openFolderItem._autorunType = AUTORUN_ITEM_OPEN_FOLDER; + this._appSection.addItem(openFolderItem); + + let doNothingItem = new Dialog.ListSectionItem({ + icon_actor: new St.Icon({ icon_name: 'window-close', icon_size: LIST_ITEM_ICON_SIZE }), + title: _("Do Nothing"), + }); + doNothingItem._autorunType = AUTORUN_ITEM_DO_NOTHING; + this._appSection.addItem(doNothingItem); } -}; -var AutorunNotification = class extends MessageTray.Notification { - constructor(manager, source) { - super(source, source.title); + _onOk() { + let selected = this._appSection.selectedItem; + if (!selected) + return; - this._manager = manager; - this._mount = source.mount; + let remember = this._alwaysCheckBox.checked; - this.source.apps.forEach(app => { - this.addButton(app.get_id(), _("Open with %s").format(app.get_name())); - }); + if (selected._autorunType === AUTORUN_ITEM_DO_NOTHING) { + if (remember && this._contentType) + _setAutorunPreferences(this._contentType, false, true, false); + else if (this._contentType) + _setAutorunPreferences(this._contentType, false, false, false); + + this.close(); + return; + } - this.connect("action-invoked", (notification, id) => { - let app = this.source.apps.find(a => a.get_id() == id); + if (selected._autorunType === AUTORUN_ITEM_OPEN_FOLDER) { + if (remember && this._contentType) + _setAutorunPreferences(this._contentType, false, false, true); + else if (this._contentType) + _setAutorunPreferences(this._contentType, false, false, false); + + let app = Gio.app_info_get_default_for_type('inode/directory', false); if (app) - startAppForMount(app, this._mount); - }); + startAppForMount(app, this.mount); + + this.close(); + return; + } + + // App selected + let app = this._apps[selected._appIndex]; + if (remember && this._contentType) { + _setAutorunPreferences(this._contentType, true, false, false); + if (app) + app.set_as_default_for_type(this._contentType); + } else if (this._contentType) { + _setAutorunPreferences(this._contentType, false, false, false); + } + + if (app) + startAppForMount(app, this.mount); + + this.close(); } - activate() { - super.activate(); + _onEject() { + if (this.mount.can_eject()) { + this.mount.eject_with_operation( + Gio.MountUnmountFlags.NONE, null, null, + (mount, res) => { + try { + mount.eject_with_operation_finish(res); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log(`Failed to eject: ${e}`); + } + } + ); + } else { + this.mount.unmount_with_operation( + Gio.MountUnmountFlags.NONE, null, null, + (mount, res) => { + try { + mount.unmount_with_operation_finish(res); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log(`Failed to unmount: ${e}`); + } + } + ); + } - let app = Gio.app_info_get_default_for_type('inode/directory', false); - startAppForMount(app, this._mount); + this.close(); } -}; + + close() { + if (this._unmountedId) { + this.mount.disconnect(this._unmountedId); + this._unmountedId = 0; + } + + super.close(); + } +}); diff --git a/js/ui/cinnamonMountOperation.js b/js/ui/cinnamonMountOperation.js index 98c01504e3..eef2db5ad4 100644 --- a/js/ui/cinnamonMountOperation.js +++ b/js/ui/cinnamonMountOperation.js @@ -115,7 +115,6 @@ var CinnamonMountOperation = class { } _onAskQuestion(op, message, choices) { - global.log("askQuestion", message, choices); this._closeExistingDialog(); this._dialog = new CinnamonMountQuestionDialog(); @@ -132,7 +131,6 @@ var CinnamonMountOperation = class { } _onAskPassword(op, message, defaultUser, defaultDomain, flags) { - global.log("askPassword", message, defaultUser, defaultDomain, flags); if (this._existingDialog) { this._dialog = this._existingDialog; this._dialog.reaskPassword(); @@ -177,7 +175,6 @@ var CinnamonMountOperation = class { _onShowProcesses2(op) { this._closeExistingDialog(); - global.log("showProcesses"); let processes = op.get_show_processes_pids(); let choices = op.get_show_processes_choices(); let message = op.get_show_processes_message(); @@ -204,7 +201,6 @@ var CinnamonMountOperation = class { } _onShowUnmountProgress(op, message, timeLeft, bytesLeft) { - global.log("show unmount prog", message, timeLeft, bytesLeft); if (!this._notifier) this._notifier = new CinnamonUnmountNotifier(); @@ -543,7 +539,6 @@ var CinnamonProcessesDialog = GObject.registerClass({ _setAppsForPids(pids) { // remove all the items this._applicationSection.list.destroy_all_children(); - global.log(pids); pids.forEach(pid => { let tracker = Cinnamon.WindowTracker.get_default(); let app = tracker.get_app_from_pid(pid); @@ -655,7 +650,6 @@ var CinnamonMountOpHandler = class { */ AskPasswordAsync(params, invocation) { let [id, message, iconName_, defaultUser_, defaultDomain_, flags] = params; - global.log("ask pass", message); if (this._setCurrentRequest(invocation, id, CinnamonMountOperationType.ASK_PASSWORD)) { this._dialog.reaskPassword(); return; @@ -705,8 +699,6 @@ var CinnamonMountOpHandler = class { */ AskQuestionAsync(params, invocation) { let [id, message, iconName_, choices] = params; - global.log("ask question", message); - if (this._setCurrentRequest(invocation, id, CinnamonMountOperationType.ASK_QUESTION)) { this._dialog.update(message, choices); return; @@ -760,7 +752,6 @@ var CinnamonMountOpHandler = class { } this._closeDialog(); - global.log("show processes "); this._dialog = new CinnamonProcessesDialog(); this._dialog.connect('response', (object, choice) => { let response; diff --git a/js/ui/dialog.js b/js/ui/dialog.js index d96b3ebcaa..01e596d378 100644 --- a/js/ui/dialog.js +++ b/js/ui/dialog.js @@ -280,10 +280,19 @@ var ListSection = GObject.registerClass({ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT, null), + 'selectable': GObject.ParamSpec.boolean( + 'selectable', 'selectable', 'selectable', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + false), + }, + Signals: { + 'selection-changed': { param_types: [GObject.TYPE_INT] }, }, }, class ListSection extends St.BoxLayout { _init(params) { this._title = new St.Label({ style_class: 'dialog-list-title' }); + this._selectedIndex = -1; this.list = new St.BoxLayout({ style_class: 'dialog-list-box', @@ -317,6 +326,58 @@ var ListSection = GObject.registerClass({ _setLabel(this._title, title); this.notify('title'); } + + get selectedIndex() { + return this._selectedIndex; + } + + get selectedItem() { + if (this._selectedIndex < 0) + return null; + + let children = this.list.get_children(); + return children[this._selectedIndex] ?? null; + } + + selectIndex(index) { + let children = this.list.get_children(); + + if (index < 0 || index >= children.length) + return; + + if (index === this._selectedIndex) + return; + + children.forEach((child, i) => { + if (i === index) + child.add_style_pseudo_class('selected'); + else + child.remove_style_pseudo_class('selected'); + }); + + this._selectedIndex = index; + this.emit('selection-changed', index); + } + + _onItemClicked(item) { + let children = this.list.get_children(); + let index = children.indexOf(item); + if (index >= 0) + this.selectIndex(index); + } + + addItem(item) { + this.list.add_child(item); + + if (this.selectable) { + item.reactive = true; + item.track_hover = true; + item.connect('button-release-event', () => { + this._onItemClicked(item); + return Clutter.EVENT_STOP; + }); + } + } }); var ListSectionItem = GObject.registerClass({ @@ -336,7 +397,7 @@ var ListSectionItem = GObject.registerClass({ GObject.ParamFlags.CONSTRUCT, null), }, -}, class ListSectionItem extends St.BoxLayout{ +}, class ListSectionItem extends St.BoxLayout { _init(params) { this._iconActorBin = new St.Bin(); @@ -361,7 +422,6 @@ var ListSectionItem = GObject.registerClass({ ...params, }); - this.label_actor = this._title; this.add_child(this._iconActorBin); this.add_child(textLayout); diff --git a/js/ui/screensaver/screenShield.js b/js/ui/screensaver/screenShield.js index d3679b0746..9f6ac0dd4c 100644 --- a/js/ui/screensaver/screenShield.js +++ b/js/ui/screensaver/screenShield.js @@ -188,7 +188,7 @@ var ScreenShield = GObject.registerClass({ this._loginManager.connect('lock', this._onSessionLock.bind(this)); this._loginManager.connect('unlock', this._onSessionUnlock.bind(this)); - this._loginManager.connect('active', this._onSessionActive.bind(this)); + this._loginManager.connect('active-changed', this._onSessionActiveChanged.bind(this)); this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', this._onMonitorsChanged.bind(this)); @@ -671,8 +671,10 @@ var ScreenShield = GObject.registerClass({ } } - _onSessionActive() { - _log(`ScreenShield: Received active signal from LoginManager (state=${this._state})`); + _onSessionActiveChanged(lm, active) { + _log(`ScreenShield: Received active-changed signal from LoginManager (active=${active}, state=${this._state})`); + if (!active) + return; if (this._state === State.LOCKED) { this.showUnlockDialog(); } From d18e951f6cdd519e61036f2e39713441e57d3b2a Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Thu, 12 Mar 2026 14:14:18 -0400 Subject: [PATCH 4/7] cinnamon-launcher: Add csd-automount to fallback helpers. --- .../theme/cinnamon-sass/widgets/_dialogs.scss | 4 + files/usr/bin/cinnamon-launcher | 14 ++ js/ui/autorunManager.js | 132 ++++++++++-------- 3 files changed, 89 insertions(+), 61 deletions(-) diff --git a/data/theme/cinnamon-sass/widgets/_dialogs.scss b/data/theme/cinnamon-sass/widgets/_dialogs.scss index 5d1866c52d..f92e1e5e8e 100644 --- a/data/theme/cinnamon-sass/widgets/_dialogs.scss +++ b/data/theme/cinnamon-sass/widgets/_dialogs.scss @@ -200,3 +200,7 @@ } } } + +.autorun-dialog { + min-width: 40em; +} diff --git a/files/usr/bin/cinnamon-launcher b/files/usr/bin/cinnamon-launcher index 06404286b8..40d31c79fb 100755 --- a/files/usr/bin/cinnamon-launcher +++ b/files/usr/bin/cinnamon-launcher @@ -73,6 +73,7 @@ class Launcher: self.polkit_agent_proc = None self.nm_applet_proc = None + self.automount_proc = None self.can_restart = False self.dialog = None @@ -161,6 +162,10 @@ class Launcher: print(f"Launching for fallback session: nm-applet") self.nm_applet_proc = subprocess.Popen(["nm-applet"]) + if shutil.which("csd-automount"): + print(f"Launching for fallback session: csd-automount") + self.automount_proc = subprocess.Popen(["csd-automount"]) + def kill_fallback_helpers(self): if self.polkit_agent_proc is not None: print("Killing fallback polkit agent") @@ -180,6 +185,15 @@ class Launcher: self.nm_applet_proc.kill() self.nm_applet_proc = None + if self.automount_proc is not None: + print("Killing fallback csd-automount") + self.automount_proc.terminate() + try: + self.automount_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.automount_proc.kill() + self.automount_proc = None + @async_function def monitor_memory(self): if psutil.pid_exists(self.cinnamon_pid): diff --git a/js/ui/autorunManager.js b/js/ui/autorunManager.js index 0d532ffb08..7a32b16fa3 100644 --- a/js/ui/autorunManager.js +++ b/js/ui/autorunManager.js @@ -7,8 +7,6 @@ const CheckBox = imports.ui.checkBox; const Dialog = imports.ui.dialog; const ModalDialog = imports.ui.modalDialog; -Gio._promisify(Gio.Mount.prototype, 'guess_content_type'); - const hotplugSnifferIface = ' \ \ @@ -40,7 +38,7 @@ const AUTORUN_ITEM_APP = 0; const AUTORUN_ITEM_OPEN_FOLDER = 1; const AUTORUN_ITEM_DO_NOTHING = 2; -var LIST_ITEM_ICON_SIZE = 24; +var LIST_ITEM_ICON_SIZE = 36; function shouldAutorunMount(mount) { let root = mount.get_root(); @@ -86,36 +84,36 @@ function startAppForMount(app, mount) { } function _getMediaGreeting(contentType) { - if (contentType === 'x-content/audio-cdda') - return _("You have just inserted an Audio CD."); - if (contentType === 'x-content/audio-dvd') - return _("You have just inserted an Audio DVD."); - if (contentType === 'x-content/video-dvd') - return _("You have just inserted a Video DVD."); - if (contentType === 'x-content/video-vcd') - return _("You have just inserted a Video CD."); - if (contentType === 'x-content/video-svcd') - return _("You have just inserted a Super Video CD."); - if (contentType === 'x-content/blank-cd') - return _("You have just inserted a blank CD."); - if (contentType === 'x-content/blank-dvd') - return _("You have just inserted a blank DVD."); - if (contentType === 'x-content/blank-bd') - return _("You have just inserted a blank Blu-Ray disc."); - if (contentType === 'x-content/blank-hddvd') - return _("You have just inserted a blank HD DVD."); - if (contentType === 'x-content/image-photocd') - return _("You have just inserted a Photo CD."); - if (contentType === 'x-content/image-picturecd') - return _("You have just inserted a Picture CD."); - if (contentType === 'x-content/image-dcf') - return _("You have just inserted a medium with digital photos."); - if (contentType === 'x-content/audio-player') - return _("You have just inserted a digital audio player."); - if (contentType && Gio.content_type_is_a(contentType, 'x-content/software')) - return _("You have just inserted a medium with software intended to be automatically started."); - - return _("You have just inserted a medium."); + // if (contentType === 'x-content/audio-cdda') + // return _("You have just inserted an Audio CD."); + // if (contentType === 'x-content/audio-dvd') + // return _("You have just inserted an Audio DVD."); + // if (contentType === 'x-content/video-dvd') + // return _("You have just inserted a Video DVD."); + // if (contentType === 'x-content/video-vcd') + // return _("You have just inserted a Video CD."); + // if (contentType === 'x-content/video-svcd') + // return _("You have just inserted a Super Video CD."); + // if (contentType === 'x-content/blank-cd') + // return _("You have just inserted a blank CD."); + // if (contentType === 'x-content/blank-dvd') + // return _("You have just inserted a blank DVD."); + // if (contentType === 'x-content/blank-bd') + // return _("You have just inserted a blank Blu-Ray disc."); + // if (contentType === 'x-content/blank-hddvd') + // return _("You have just inserted a blank HD DVD."); + // if (contentType === 'x-content/image-photocd') + // return _("You have just inserted a Photo CD."); + // if (contentType === 'x-content/image-picturecd') + // return _("You have just inserted a Picture CD."); + // if (contentType === 'x-content/image-dcf') + // return _("You have just inserted a medium with digital photos."); + // if (contentType === 'x-content/audio-player') + // return _("You have just inserted a digital audio player."); + // if (contentType && Gio.content_type_is_a(contentType, 'x-content/software')) + // return _("You have just inserted a medium with software intended to be automatically started."); + + return _("Media inserted"); } function _setAutorunPreferences(contentType, startApp, ignore, openFolder) { @@ -149,25 +147,41 @@ var ContentTypeDiscoverer = class { this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); } - async guessContentTypes(mount) { + guessContentTypes(mount, callback) { let autorunEnabled = !this._settings.get_boolean(SETTING_DISABLE_AUTORUN); let shouldScan = autorunEnabled && !isMountNonLocal(mount); - let contentTypes = []; - if (shouldScan) { + if (!shouldScan) { + callback([], []); + return; + } + + mount.guess_content_type(false, null, (mount, res) => { + let contentTypes = []; + try { - contentTypes = await mount.guess_content_type(false, null); + contentTypes = mount.guess_content_type_finish(res); } 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()); + let root = mount.get_root(); + let hotplugSniffer = new HotplugSniffer(); + hotplugSniffer.SniffURIRemote(root.get_uri(), (result, error) => { + if (!error && result) + contentTypes = result[0] || []; + + this._resolveApps(contentTypes, callback); + }); + return; } - } + this._resolveApps(contentTypes, callback); + }); + } + + _resolveApps(contentTypes, callback) { contentTypes = contentTypes.filter( type => type !== 'x-content/win32-software'); @@ -179,7 +193,7 @@ var ContentTypeDiscoverer = class { apps.push(app); }); - return [apps, contentTypes]; + callback(apps, contentTypes); } }; @@ -202,22 +216,22 @@ var AutorunManager = class { this._volumeMonitor.disconnectObject(this); } - async _onMountAdded(monitor, mount) { + _onMountAdded(monitor, mount) { if (!shouldAutorunMount(mount)) return; const discoverer = new ContentTypeDiscoverer(); - const [apps, contentTypes] = await discoverer.guessContentTypes(mount); + discoverer.guessContentTypes(mount, (apps, contentTypes) => { + log(`autorunManager: mount=${mount.get_name()} contentTypes=[${contentTypes}] apps=[${apps.map(a => a.get_name())}]`); - log(`autorunManager: mount=${mount.get_name()} contentTypes=[${contentTypes}] apps=[${apps.map(a => a.get_name())}]`); - - if (apps.length === 0) { - if (this._settings.get_boolean(SETTING_AUTOMOUNT_OPEN)) - this._openFolderForMount(mount); - return; - } + if (apps.length === 0) { + if (this._settings.get_boolean(SETTING_AUTOMOUNT_OPEN)) + this._openFolderForMount(mount); + return; + } - this._dispatcher.addMount(mount, apps, contentTypes); + this._dispatcher.addMount(mount, apps, contentTypes); + }); } _openFolderForMount(mount) { @@ -276,9 +290,6 @@ var AutorunDispatcher = class { if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN)) return; - if (!shouldAutorunMount(mount)) - return; - let contentType = contentTypes.length > 0 ? contentTypes[0] : null; let setting; @@ -325,8 +336,8 @@ class AutorunDialog extends ModalDialog.ModalDialog { let mountName = mount.get_name(); - let greeting = _getMediaGreeting(contentType); - let title = `${greeting} ${_("Choose what application to launch.")}`; + // let greeting = _getMediaGreeting(contentType); + let title = _("Media inserted"); let contentDescription = contentType ? Gio.content_type_get_description(contentType) @@ -334,10 +345,9 @@ class AutorunDialog extends ModalDialog.ModalDialog { let description; if (contentDescription) { - description = _("Select how to open \"%s\" and whether to perform this action in the future for other media of type \"%s\".") - .format(mountName, contentDescription); + description = `${mountName}\n(${contentDescription})\n\n${_("Choose an action")}`; } else { - description = _("Select how to open \"%s\".").format(mountName); + description = _("Select how to open '%s'.").format(mountName); } this._content = new Dialog.MessageDialogContent({ title, description }); @@ -392,7 +402,7 @@ class AutorunDialog extends ModalDialog.ModalDialog { }); let openFolderItem = new Dialog.ListSectionItem({ - icon_actor: new St.Icon({ icon_name: 'folder-open', icon_size: LIST_ITEM_ICON_SIZE }), + icon_actor: new St.Icon({ icon_name: 'folder-open', icon_size: LIST_ITEM_ICON_SIZE, icon_type: St.IconType.FULLCOLOR }), title: _("Open Folder"), }); openFolderItem._autorunType = AUTORUN_ITEM_OPEN_FOLDER; From 6c4d78d3f50a98526f5e478a3af27cc058b0f82a Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Fri, 13 Mar 2026 20:45:46 -0400 Subject: [PATCH 5/7] AutorunDialog: don't use a MessageDialogContent, make our own content. Our messages are better presented as left-justified. - Improve list/list-item styling. - Add a temporary dummy dialog for testing. --- .../theme/cinnamon-sass/widgets/_dialogs.scss | 43 ++++++++++- js/ui/autorunManager.js | 71 ++++++------------- js/ui/testAutorunDialog.js | 31 ++++++++ 3 files changed, 96 insertions(+), 49 deletions(-) create mode 100644 js/ui/testAutorunDialog.js diff --git a/data/theme/cinnamon-sass/widgets/_dialogs.scss b/data/theme/cinnamon-sass/widgets/_dialogs.scss index f92e1e5e8e..bcdf8a292c 100644 --- a/data/theme/cinnamon-sass/widgets/_dialogs.scss +++ b/data/theme/cinnamon-sass/widgets/_dialogs.scss @@ -41,6 +41,8 @@ .dialog-list-box { spacing: 1em; + border-radius: $base_border_radius; + .dialog-list-item { spacing: 1em; border-radius: $base_border_radius; @@ -202,5 +204,44 @@ } .autorun-dialog { - min-width: 40em; + min-width: 30em; + + .dialog-content-box { + margin-top: $base_margin; + margin-bottom: $base_margin * 3; + spacing: $base_margin * 3; + max-width: 28em; + + .dialog-list { + .dialog-list-box { + background-color: lighten($bg_color, 5%); + spacing: 0; + + .dialog-list-item { + border-radius: 0; + + &:first-child { + border-radius: $base_border_radius $base_border_radius 0 0; + } + + &:last-child { + border-radius: 0 0 $base_border_radius $base_border_radius; + } + } + } + } + + .autorun-dialog-heading { + @extend %title_2; + text-align: center; + } + + .autorun-dialog-subheading { + @extend %title_4; + text-align: center; + } + + .autorun-dialog-description { + } + } } diff --git a/js/ui/autorunManager.js b/js/ui/autorunManager.js index 7a32b16fa3..518aed7397 100644 --- a/js/ui/autorunManager.js +++ b/js/ui/autorunManager.js @@ -1,7 +1,7 @@ // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- /* exported Component */ -const { Clutter, Gio, GObject, St } = imports.gi; +const { Clutter, Gio, GObject, Pango, St } = imports.gi; const CheckBox = imports.ui.checkBox; const Dialog = imports.ui.dialog; @@ -83,39 +83,6 @@ function startAppForMount(app, mount) { return false; } -function _getMediaGreeting(contentType) { - // if (contentType === 'x-content/audio-cdda') - // return _("You have just inserted an Audio CD."); - // if (contentType === 'x-content/audio-dvd') - // return _("You have just inserted an Audio DVD."); - // if (contentType === 'x-content/video-dvd') - // return _("You have just inserted a Video DVD."); - // if (contentType === 'x-content/video-vcd') - // return _("You have just inserted a Video CD."); - // if (contentType === 'x-content/video-svcd') - // return _("You have just inserted a Super Video CD."); - // if (contentType === 'x-content/blank-cd') - // return _("You have just inserted a blank CD."); - // if (contentType === 'x-content/blank-dvd') - // return _("You have just inserted a blank DVD."); - // if (contentType === 'x-content/blank-bd') - // return _("You have just inserted a blank Blu-Ray disc."); - // if (contentType === 'x-content/blank-hddvd') - // return _("You have just inserted a blank HD DVD."); - // if (contentType === 'x-content/image-photocd') - // return _("You have just inserted a Photo CD."); - // if (contentType === 'x-content/image-picturecd') - // return _("You have just inserted a Picture CD."); - // if (contentType === 'x-content/image-dcf') - // return _("You have just inserted a medium with digital photos."); - // if (contentType === 'x-content/audio-player') - // return _("You have just inserted a digital audio player."); - // if (contentType && Gio.content_type_is_a(contentType, 'x-content/software')) - // return _("You have just inserted a medium with software intended to be automatically started."); - - return _("Media inserted"); -} - function _setAutorunPreferences(contentType, startApp, ignore, openFolder) { let settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); @@ -335,23 +302,29 @@ class AutorunDialog extends ModalDialog.ModalDialog { this._contentType = contentType; let mountName = mount.get_name(); - - // let greeting = _getMediaGreeting(contentType); - let title = _("Media inserted"); - let contentDescription = contentType ? Gio.content_type_get_description(contentType) : null; - let description; - if (contentDescription) { - description = `${mountName}\n(${contentDescription})\n\n${_("Choose an action")}`; - } else { - description = _("Select how to open '%s'.").format(mountName); - } + let heading = new St.Label({ + style_class: 'autorun-dialog-heading', + text: _("New media detected"), + x_align: Clutter.ActorAlign.CENTER, + }); + this.contentLayout.add_child(heading); + + let descriptionText; + descriptionText = _("Select how to open '%s' and whether to perform this action in the future.") + .format(mountName, contentDescription); - this._content = new Dialog.MessageDialogContent({ title, description }); - this.contentLayout.add_child(this._content); + let description = new St.Label({ + style_class: 'autorun-dialog-description', + text: descriptionText, + }); + description.clutter_text.line_wrap = true; + description.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + description.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this.contentLayout.add_child(description); this._appSection = new Dialog.ListSection({ selectable: true }); this.contentLayout.add_child(this._appSection); @@ -359,8 +332,10 @@ class AutorunDialog extends ModalDialog.ModalDialog { this._populateAppList(); this._appSection.selectIndex(0); - this._alwaysCheckBox = new CheckBox.CheckBox(_("Always perform this action")); - this.contentLayout.add_child(this._alwaysCheckBox); + if (contentDescription) { + this._alwaysCheckBox = new CheckBox.CheckBox(_("Always perform this action for type '%s'").format(contentDescription)); + this.contentLayout.add_child(this._alwaysCheckBox); + } let canEject = mount.can_eject(); let ejectLabel = canEject ? _("Eject") : _("Unmount"); diff --git a/js/ui/testAutorunDialog.js b/js/ui/testAutorunDialog.js new file mode 100644 index 0000000000..27ad6ad089 --- /dev/null +++ b/js/ui/testAutorunDialog.js @@ -0,0 +1,31 @@ +// Test helper - call from Looking Glass: +// imports.ui.testAutorunDialog.show() +// imports.ui.testAutorunDialog.show("My USB", "x-content/image-dcf") + +const Gio = imports.gi.Gio; +const AutorunManager = imports.ui.autorunManager; + +function show(mountName, contentType) { + mountName = mountName || "Test USB Drive"; + contentType = contentType || "x-content/unix-software"; + + let nextId = 1; + let mount = { + get_name() { return mountName; }, + can_eject() { return true; }, + get_root() { return Gio.File.new_for_path("/"); }, + connect() { return nextId++; }, + disconnect() {}, + eject_with_operation() {}, + unmount_with_operation() {}, + }; + + let apps = []; + let app = Gio.app_info_get_default_for_type("inode/directory", false); + if (app) + apps.push(app); + + let dialog = new AutorunManager.AutorunDialog(mount, apps, contentType); + dialog.open(); + return dialog; +} From ad846c756807ee06c73bf54e13e0c63cee068e40 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Mon, 16 Mar 2026 14:52:27 -0400 Subject: [PATCH 6/7] add testMountDialogs.js --- js/ui/testMountDialogs.js | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 js/ui/testMountDialogs.js diff --git a/js/ui/testMountDialogs.js b/js/ui/testMountDialogs.js new file mode 100644 index 0000000000..37b7a67fd0 --- /dev/null +++ b/js/ui/testMountDialogs.js @@ -0,0 +1,63 @@ +// Test helpers for mount operation dialogs. +// Call from Looking Glass: +// +// imports.ui.testMountDialogs.askPassword() +// imports.ui.testMountDialogs.askPassword("Unlock encrypted volume\nEnter password for 'My Drive'") +// imports.ui.testMountDialogs.askPasswordTcrypt() +// imports.ui.testMountDialogs.askQuestion() +// imports.ui.testMountDialogs.askQuestion("Trust this certificate?\nThe identity of 'server.local' cannot be verified.", ["Cancel", "Trust", "Trust Always"]) +// imports.ui.testMountDialogs.showProcesses() + +const Gio = imports.gi.Gio; +const MountOp = imports.ui.cinnamonMountOperation; + +function askPassword(message, flags) { + message = message || "Enter a password to unlock the volume\nThe password is needed to access encrypted data on My Encrypted Drive."; + flags = flags || Gio.AskPasswordFlags.NEED_PASSWORD; + + let dialog = new MountOp.CinnamonMountPasswordDialog(message, flags); + dialog.connect('response', (obj, choice, password, remember, hidden, system, pim) => { + log(`[testMountDialogs] askPassword response: choice=${choice} password=${password}`); + dialog.close(); + }); + dialog.open(); + return dialog; +} + +function askPasswordTcrypt(message) { + message = message || "Enter a password to unlock the volume\nThe password is needed to access encrypted data on My VeraCrypt Drive."; + let flags = Gio.AskPasswordFlags.NEED_PASSWORD | Gio.AskPasswordFlags.TCRYPT; + + return askPassword(message, flags); +} + +function askQuestion(message, choices) { + message = message || "Mount point is not empty\nThe mount point /mnt/data already contains files. Do you want to merge?"; + choices = choices || ["Cancel", "Merge"]; + + let dialog = new MountOp.CinnamonMountQuestionDialog(); + dialog.connect('response', (obj, choice) => { + log(`[testMountDialogs] askQuestion response: choice=${choice}`); + dialog.close(); + }); + dialog.update(message, choices); + dialog.open(); + return dialog; +} + +function showProcesses(message, choices) { + message = message || "Volume is busy\nOne or more applications are keeping the volume busy."; + choices = choices || ["Cancel", "Unmount Anyway"]; + + let dialog = new MountOp.CinnamonProcessesDialog(); + dialog.connect('response', (obj, choice) => { + log(`[testMountDialogs] showProcesses response: choice=${choice}`); + dialog.close(); + }); + + // Use real PIDs of running apps if possible, otherwise empty + let pids = []; + dialog.update(message, pids, choices); + dialog.open(); + return dialog; +} From b90c6ce49f302f1a1a158998a105af2fa8105a2d Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Mon, 16 Mar 2026 15:19:15 -0400 Subject: [PATCH 7/7] cinnamonMountOperation.js: Use StEntry's busy indicator, add setting for 'remember' checkbox default state. --- data/org.cinnamon.gschema.xml | 9 ++++++++ js/ui/cinnamonMountOperation.js | 40 +++++++++++++-------------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/data/org.cinnamon.gschema.xml b/data/org.cinnamon.gschema.xml index e01fd3a313..803ec66abe 100644 --- a/data/org.cinnamon.gschema.xml +++ b/data/org.cinnamon.gschema.xml @@ -611,6 +611,15 @@ "" Stores the position of the hoverclick window so it can be restored there later - format is x::y + + false + Whether to remember password for mounting encrypted or remote filesystems + + When an encrypted device or remote filesystem is mounted, a password + dialog may include a "Remember Password" checkbox. This key stores the + default state of that checkbox. + +