From ad8a48afe01f7ff25ea5d498d0643509c543f931 Mon Sep 17 00:00:00 2001 From: Fabio Fantoni Date: Sat, 25 Jul 2026 15:56:55 +0200 Subject: [PATCH 1/3] cinnamonMountOperation.js: Fix the icon name used by the unmount notification. 'xapp-media-removable' does not exist in the icon set Cinnamon uses. Icon names were switched to XSI in 890f6f7f9, and xapp-symbolic-icons only ships xsi-* names, so the unmount notification has been falling back to a missing icon since it was added. 'xsi-media-removable' is the direct equivalent and shows the same removable drive. Assisted-by: Claude Code:claude-opus-5 --- js/ui/cinnamonMountOperation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/ui/cinnamonMountOperation.js b/js/ui/cinnamonMountOperation.js index 191136e2e4..1a8ea7f86e 100644 --- a/js/ui/cinnamonMountOperation.js +++ b/js/ui/cinnamonMountOperation.js @@ -253,7 +253,7 @@ var CinnamonUnmountNotifier = class extends MessageTray.Source { createNotificationIcon () { return new St.Icon({ - icon_name: 'xapp-media-removable', + icon_name: 'xsi-media-removable', icon_type: St.IconType.SYMBOLIC, icon_size: this.ICON_SIZE }); From e5fb16fc717ff1c5339b4d75b7a86811d3c7e62c Mon Sep 17 00:00:00 2001 From: Fabio Fantoni Date: Sat, 25 Jul 2026 15:58:32 +0200 Subject: [PATCH 2/3] automountManager.js: Warn when a device is unplugged while still mounted. Users who physically pull out a drive without ejecting it get no feedback at all, so nothing tells them that they risk losing data. This cannot be prevented, but it can be reported after the fact. GVfs makes the distinction detectable: update_all() in gvfsudisks2volumemonitor.c emits drive-disconnected before volume-removed and mount-removed for everything that disappeared in the same update. A device that is unplugged while mounted therefore still has its mounts recorded when drive-disconnected arrives, while a device that was unmounted or ejected first had mount-removed delivered by an earlier update. Traced against GVfs 1.54 with a USB stick: eject, safe removal and a plain command line unmount all leave the record empty, while unplugging a mounted stick does not. The mounts are tracked in a cache keyed on the mount point, because GMount has no volume left by the time mount-removed is emitted, and drive.get_volumes() is unreliable here: it is empty for a clean removal and can still list a volume after a clean unmount. Report this with a notification rather than a modal dialog, matching how CinnamonUnmountNotifier already reports unmount progress: the user did not ask for this and there is nothing to answer. Warn at most once per minute per device, since a failing connector can produce repeated connect/disconnect cycles. Also add js/ui/automountManager.js to POTFILES.in, which never listed it, so its strings can be translated. Assisted-by: Claude Code:claude-opus-5 --- js/testing/testUnsafeRemovalWarning.js | 12 ++ js/ui/automountManager.js | 148 ++++++++++++++++++++++++- po/POTFILES.in | 1 + 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 js/testing/testUnsafeRemovalWarning.js diff --git a/js/testing/testUnsafeRemovalWarning.js b/js/testing/testUnsafeRemovalWarning.js new file mode 100644 index 0000000000..4f011b0790 --- /dev/null +++ b/js/testing/testUnsafeRemovalWarning.js @@ -0,0 +1,12 @@ +// Test helper for the unsafe removal warning. +// Call from Looking Glass: +// +// imports.testing.testUnsafeRemovalWarning.show() +// imports.testing.testUnsafeRemovalWarning.show("My USB Stick") +// imports.testing.testUnsafeRemovalWarning.show(null) // unnamed device + +const AutomountManager = imports.ui.automountManager; + +function show(driveName = "JetFlash Transcend 4GB") { + AutomountManager.notifyUnsafeRemoval(driveName); +} diff --git a/js/ui/automountManager.js b/js/ui/automountManager.js index 773bbdd182..8f895d389f 100644 --- a/js/ui/automountManager.js +++ b/js/ui/automountManager.js @@ -1,7 +1,7 @@ // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- /* exported Component */ -const { Gio, GLib } = imports.gi; +const { Gio, GLib, St } = imports.gi; const Params = imports.misc.params; const LoginManager = imports.misc.loginManager; @@ -14,11 +14,56 @@ const SETTING_ENABLE_AUTOMOUNT = 'automount'; var AUTORUN_EXPIRE_TIMEOUT_SECS = 10; +// A device with a failing connector can produce repeated connect/disconnect +// cycles, so warn at most once per device within this window. +var UNSAFE_REMOVAL_WARNING_TIMEOUT_SECS = 60; + +// Reports a device that was unplugged while still mounted. This is delivered as +// a notification rather than a modal dialog: the user did not ask for it, so it +// belongs with the unmount progress notifications (CinnamonUnmountNotifier) +// rather than with the dialogs used for password or busy-device prompts. +function notifyUnsafeRemoval(driveName) { + let body; + if (driveName) { + body = _("“%s” was unplugged before it was safely removed.").format(driveName); + } else { + body = _("A device was unplugged before it was safely removed."); + } + + body += '\n\n'; + // Not just files being written at that moment: the kernel can hold changes + // in the page cache long after a copy appears to be finished, and + // interrupted metadata updates can damage the whole filesystem. + body += _("The data on it may be lost or damaged. Even when no file seems to be in use, the system can still have changes waiting to be written to the device."); + + body += '\n\n'; + // Safe removal and eject both act on the drive, so gvfs notifies + // "... can be safely unplugged" once either one completes. Safe removal is + // named first because it powers the device off (what the drives applet does + // through PlaceDeviceItem._tryRemove), while eject leaves it powered and the + // kernel re-enumerates it, which looks to the user as if nothing happened. + body += _("Next time, use “Safely Remove Drive” (or “Eject”) from the device menu, and unplug the device only once you are told that it can be removed."); + + let icon = new St.Icon({ + icon_name: 'xsi-media-removable', + icon_type: St.IconType.SYMBOLIC, + icon_size: 24, + }); + + // Not transient: the device is already gone, so the warning is only useful + // if it waits in the tray for the user to actually read it. + Main.criticalNotify(_("Device removed unsafely"), body, icon); +} + var AutomountManager = class { constructor() { this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); this._activeOperations = new Map(); this._volumeQueue = []; + // mount root path -> drive key, for mounts that are still live + this._mountedDrives = new Map(); + // driveKey -> timeout id, suppressing repeated warnings + this._unsafeRemovalWarnings = new Map(); this._loginManager = LoginManager.getLoginManager(); this._loginManager.connect('active-changed', (lm, active) => { @@ -39,6 +84,8 @@ var AutomountManager = class { this._volumeMonitor.connectObject( 'volume-added', this._onVolumeAdded.bind(this), 'volume-removed', this._onVolumeRemoved.bind(this), + 'mount-added', this._onMountAdded.bind(this), + 'mount-removed', this._onMountRemoved.bind(this), 'drive-connected', this._onDriveConnected.bind(this), 'drive-disconnected', this._onDriveDisconnected.bind(this), 'drive-eject-button', this._onDriveEjectButton.bind(this), this); @@ -54,6 +101,10 @@ var AutomountManager = class { GLib.source_remove(this._mountAllId); this._mountAllId = 0; } + + this._unsafeRemovalWarnings.forEach(id => GLib.source_remove(id)); + this._unsafeRemovalWarnings.clear(); + this._mountedDrives.clear(); } _drainVolumeQueue() { @@ -66,6 +117,12 @@ var AutomountManager = class { } _startupMountAll() { + // Mounts that already existed when we started (e.g. after a Cinnamon + // restart) never emit mount-added, so seed the cache with them. + this._volumeMonitor.get_mounts().forEach(mount => { + this._trackMount(mount); + }); + let volumes = this._volumeMonitor.get_volumes(); volumes.forEach(volume => { this._checkAndMountVolume(volume, { @@ -89,7 +146,7 @@ var AutomountManager = class { null); } - _onDriveDisconnected() { + _onDriveDisconnected(monitor, drive) { if (!this._loginManager.sessionIsActive) return; @@ -97,6 +154,93 @@ var AutomountManager = class { player.play_from_theme('device-removed-media', _("External drive disconnected"), null); + + this._checkUnsafeRemoval(drive); + } + + // A GMount has no volume left by the time mount-removed is emitted, so the + // cache is keyed on the mount point instead. + _mountKey(mount) { + const root = mount.get_root(); + if (!root) + return null; + + return root.get_path() ?? root.get_uri(); + } + + // The device node can be reassigned across a reconnect (sda -> sdb) and the + // GObject instance is always a new one, so identify a drive by its name. + _driveKey(drive) { + return drive.get_name() ?? + drive.get_identifier(Gio.DRIVE_IDENTIFIER_KIND_UNIX_DEVICE); + } + + _trackMount(mount) { + const drive = mount.get_drive(); + + // Only hotpluggable devices can be pulled out from under us. + if (!drive || !drive.is_removable()) + return; + + const key = this._mountKey(mount); + if (key === null) + return; + + this._mountedDrives.set(key, this._driveKey(drive)); + } + + _onMountAdded(monitor, mount) { + this._trackMount(mount); + } + + _onMountRemoved(monitor, mount) { + const key = this._mountKey(mount); + if (key !== null) + this._mountedDrives.delete(key); + } + + // Detects a device that was physically unplugged while still mounted. + // + // This relies on the order in which GVfs emits its removal signals: in + // gvfsudisks2volumemonitor.c, update_all() emits drive-disconnected before + // volume-removed and mount-removed for everything that vanished in the same + // update. So when a device is pulled out, its mounts are still in the cache + // at this point, while a device that was unmounted or ejected first had its + // mount-removed delivered by an earlier update and left the cache empty. + // + // Verified by tracing GVolumeMonitor against a USB stick on GVfs 1.54: + // unplugging while mounted leaves the mounts in the cache here, whereas + // eject, safe removal and a plain unmount all leave it empty. + _checkUnsafeRemoval(drive) { + if (!drive) + return; + + const driveKey = this._driveKey(drive); + let removedMounts = 0; + + for (let [mountKey, key] of this._mountedDrives) { + if (key !== driveKey) + continue; + + this._mountedDrives.delete(mountKey); + removedMounts++; + } + + if (removedMounts === 0) + return; + + if (this._unsafeRemovalWarnings.has(driveKey)) + return; + + const id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, + UNSAFE_REMOVAL_WARNING_TIMEOUT_SECS, () => { + this._unsafeRemovalWarnings.delete(driveKey); + return GLib.SOURCE_REMOVE; + }); + this._unsafeRemovalWarnings.set(driveKey, id); + GLib.Source.set_name_by_id(id, '[cinnamon] unsafe removal warning'); + + notifyUnsafeRemoval(drive.get_name()); } _onDriveEjectButton(monitor, drive) { diff --git a/po/POTFILES.in b/po/POTFILES.in index 443e3a52e7..27e875e095 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -142,6 +142,7 @@ js/ui/appSwitcher/appSwitcher.js js/ui/appSwitcher/appSwitcher3D.js js/ui/appSwitcher/classicSwitcher.js js/ui/appSwitcher/coverflowSwitcher.js +js/ui/automountManager.js js/ui/locatePointer.js js/ui/lookingGlass.js js/ui/magnifier.js From 74de731e57de1b6a87156df7b5fb79293370d8a4 Mon Sep 17 00:00:00 2001 From: Fabio Fantoni Date: Sat, 25 Jul 2026 16:00:26 +0200 Subject: [PATCH 3/3] removable-drives@cinnamon.org: Use a more recognizable panel icon. 'xsi-drive-removable-media' draws a plain rounded rectangle that reads as a floppy or an optical drive, so at panel size it doesn't suggest removable devices at all. 'xsi-media-removable' draws a USB stick, which is what users are looking for, and is the icon the unmount notification uses as well. Assisted-by: Claude Code:claude-opus-5 --- .../cinnamon/applets/removable-drives@cinnamon.org/applet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/usr/share/cinnamon/applets/removable-drives@cinnamon.org/applet.js b/files/usr/share/cinnamon/applets/removable-drives@cinnamon.org/applet.js index f0a3d547d7..6b16ba0ad0 100644 --- a/files/usr/share/cinnamon/applets/removable-drives@cinnamon.org/applet.js +++ b/files/usr/share/cinnamon/applets/removable-drives@cinnamon.org/applet.js @@ -41,7 +41,7 @@ class CinnamonRemovableDrivesApplet extends Applet.IconApplet { constructor(orientation, panel_height, instance_id) { super(orientation, panel_height, instance_id); - this.set_applet_icon_symbolic_name("xsi-drive-removable-media"); + this.set_applet_icon_symbolic_name("xsi-media-removable"); this.set_applet_tooltip(_("Removable drives")); global.settings.connect('changed::' + PANEL_EDIT_MODE_KEY, Lang.bind(this, this._onPanelEditModeChanged));