From 43cc02215dd93704ab19fb40d13f991445290c83 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Thu, 5 Mar 2026 09:26:54 -0500 Subject: [PATCH] screensaver: Do more to acquire a modal grab attempt when locking. cinnamon-screensaver made muultiple attempts to acquire a modal 'grab' when activating, including using xdo to cancel ui toolkit popups. - Add an asynchronous pushScreensaverModal to do the same. - Screensaver service 'response' is delayed until this ultimately succeeds or fails. - Suspend inhibitor state is held during this time also. --- js/ui/main.js | 92 +++++++++---- js/ui/screensaver/screenShield.js | 138 +++++++++++++------ src/cinnamon-global-private.h | 15 +++ src/cinnamon-global.c | 217 +++++++++++++++++++++++++++++- src/cinnamon-global.h | 11 ++ src/meson.build | 1 + 6 files changed, 402 insertions(+), 72 deletions(-) diff --git a/js/ui/main.js b/js/ui/main.js index 7a5d17b00f..6fffcee476 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -1318,6 +1318,40 @@ function _findModal(actor) { return -1; } +function _completeModalSetup(actor, mode) { + if (modalCount == 0) + Meta.disable_unredirect_for_display(global.display); + + global.set_stage_input_mode(Cinnamon.StageInputMode.FULLSCREEN); + + actionMode = mode; + + modalCount += 1; + let actorDestroyId = actor.connect('destroy', function() { + let index = _findModal(actor); + if (index >= 0) + popModal(actor); + }); + + let record = { + actor: actor, + focus: global.stage.get_key_focus(), + destroyId: actorDestroyId, + actionMode: mode + }; + if (record.focus != null) { + record.focusDestroyId = record.focus.connect('destroy', function() { + record.focus = null; + record.focusDestroyId = null; + }); + } + modalActorFocusStack.push(record); + + global.stage.set_key_focus(actor); + + layoutManager.updateChrome(true); +} + /** * pushModal: * @actor (Clutter.Actor): actor which will be given keyboard focus @@ -1356,38 +1390,44 @@ function pushModal(actor, timestamp, options, mode) { log('pushModal: invocation of begin_modal failed'); return false; } - Meta.disable_unredirect_for_display(global.display); } - global.set_stage_input_mode(Cinnamon.StageInputMode.FULLSCREEN); + _completeModalSetup(actor, mode); + return true; +} - actionMode = mode; +/** + * pushScreensaverModal: + * @actor (Clutter.Actor): actor which will be given keyboard focus. + * @timestamp (number): optional X server timestamp. + * @mode (Cinnamon.ActionMode): the action mode for the modal grab. + * @callback (function): called with (success) when the grab completes. + * + * Like pushModal(), but uses begin_modal_with_retry() to asynchronously + * retry the grab on X11, using libxdo to break stuck grabs from popup + * menus. The callback is called with true on success, false on failure. + */ +function pushScreensaverModal(actor, timestamp, mode, callback) { + if (timestamp == undefined) + timestamp = global.get_current_time(); - modalCount += 1; - let actorDestroyId = actor.connect('destroy', function() { - let index = _findModal(actor); - if (index >= 0) - popModal(actor); - }); + global.begin_modal_with_retry(timestamp, 0, + (obj, success) => { + if (!success) { + log('pushScreensaverModal: failed to acquire modal grab after retries (or cancelled)'); + callback(false); + return; + } - let record = { - actor: actor, - focus: global.stage.get_key_focus(), - destroyId: actorDestroyId, - actionMode: mode - }; - if (record.focus != null) { - record.focusDestroyId = record.focus.connect('destroy', function() { - record.focus = null; - record.focusDestroyId = null; + try { + _completeModalSetup(actor, mode); + callback(true); + } catch (e) { + global.logError(`pushScreensaverModal: error during modal setup: ${e.message}`); + global.end_modal(global.get_current_time()); + callback(false); + } }); - } - modalActorFocusStack.push(record); - - global.stage.set_key_focus(actor); - - layoutManager.updateChrome(true); - return true; } /** diff --git a/js/ui/screensaver/screenShield.js b/js/ui/screensaver/screenShield.js index 2b7e92fb4d..4105be18c0 100644 --- a/js/ui/screensaver/screenShield.js +++ b/js/ui/screensaver/screenShield.js @@ -129,6 +129,7 @@ var ScreenShield = GObject.registerClass({ this._widgetLoadIdleId = 0; this._infoPanel = null; this._inhibitor = null; + this._activationPending = false; this._nameBlocker = new NameBlocker.NameBlocker(); @@ -356,19 +357,26 @@ var ScreenShield = GObject.registerClass({ } lock(immediate = false, awayMessage = null) { - if (this.isLocked()) + if (this.isLocked() || this._activationPending) { + _log('ScreenShield: Already locked or activation pending, ignoring lock request'); return; + } this._awayMessage = awayMessage; _log(`ScreenShield: Locking screen (immediate=${immediate})`); if (this._state === State.HIDDEN) { - this.activate(immediate); + this.activate(immediate, (success) => { + if (success) { + this._stopLockDelay(); + this._setLocked(true); + } + }); + } else { + this._stopLockDelay(); + this._setLocked(true); } - - this._stopLockDelay(); - this._setLocked(true); } _setLocked(locked) { @@ -397,9 +405,9 @@ var ScreenShield = GObject.registerClass({ this._hideShield(true); } - activate(immediate = false) { - if (this._state !== State.HIDDEN) { - _log('ScreenShield: Already active'); + activate(immediate = false, lock_callback = null) { + if (this._state !== State.HIDDEN || this._activationPending) { + _log('ScreenShield: Already active or activation pending'); return; } @@ -409,44 +417,87 @@ var ScreenShield = GObject.registerClass({ this._lastMotionY = -1; this._activationTime = GLib.get_monotonic_time(); - if (!Main.pushModal(this, global.get_current_time(), 0, Cinnamon.ActionMode.LOCK_SCREEN)) { - global.logError('ScreenShield: Failed to acquire modal grab'); - return; - } + this._activationPending = true; + _log('ScreenShield: requesting screensaver modal grab'); + Main.pushScreensaverModal(this, global.get_current_time(), Cinnamon.ActionMode.LOCK_SCREEN, + (success) => { + this._activationPending = false; - this._setState(State.SHOWN); + if (success) { + _log('ScreenShield: modal grab acquired, finishing activation'); - this._createBackgrounds(); + if (this._finishActivation(immediate)) { + if (lock_callback) + lock_callback(true); - this._capturedEventId = global.stage.connect('captured-event', - this._onCapturedEvent.bind(this)); + return; + } else { + Main.popModal(this); + } + } - global.stage.hide_cursor(); + global.logWarning('ScreenShield: Failed to acquire modal grab (or cancelled)'); + this._activationTime = 0; + this._syncInhibitor(); + if (lock_callback) + lock_callback(false); + }); + } - if (Main.deskletContainer) - Main.deskletContainer.actor.hide(); + _finishActivation(immediate) { + try { + this._createBackgrounds(); - Main.layoutManager.screenShieldGroup.show(); - this.show(); + this._capturedEventId = global.stage.connect('captured-event', + this._onCapturedEvent.bind(this)); - if (immediate) { - this.opacity = 255; - this._activateBackupLocker(); - this._scheduleWidgetLoading(); - } else { - this.opacity = 0; - this.ease({ - opacity: 255, - duration: FADE_TIME, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - onComplete: () => { - this._activateBackupLocker(); - this._scheduleWidgetLoading(); - } - }); - } + global.stage.hide_cursor(); + + if (Main.deskletContainer) + Main.deskletContainer.actor.hide(); + + Main.layoutManager.screenShieldGroup.show(); + this.show(); + + if (immediate) { + this.opacity = 255; + this._activateBackupLocker(); + this._scheduleWidgetLoading(); + } else { + this.opacity = 0; + this.ease({ + opacity: 255, + duration: FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._activateBackupLocker(); + this._scheduleWidgetLoading(); + } + }); + } + + this._setState(State.SHOWN); + this._startLockDelay(); + return true; + } catch (e) { + global.logError(`ScreenShield: error during activation: ${e.message}`); + + if (this._capturedEventId) { + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + + global.stage.show_cursor(); + + if (Main.deskletContainer) + Main.deskletContainer.actor.show(); - this._startLockDelay(); + Main.layoutManager.screenShieldGroup.hide(); + this.hide(); + this._destroyBackgrounds(); + + return false; + } } _startLockDelay() { @@ -495,6 +546,12 @@ var ScreenShield = GObject.registerClass({ return; } + if (this._activationPending) { + _log('ScreenShield: Cancelling pending activation'); + global.end_modal(global.get_current_time()); + return; + } + this._stopLockDelay(); this._hideShield(false); } @@ -552,7 +609,8 @@ var ScreenShield = GObject.registerClass({ _syncInhibitor() { let lockEnabled = this._settings.get_boolean('lock-enabled'); let lockDisabled = Main.lockdownSettings.get_boolean('disable-lock-screen'); - let shouldInhibit = this._state === State.HIDDEN && lockEnabled && !lockDisabled; + let shouldInhibit = (this._state === State.HIDDEN || this._activationPending) && + lockEnabled && !lockDisabled; if (shouldInhibit && !this._inhibitor) { _log('ScreenShield: Acquiring sleep inhibitor'); @@ -563,7 +621,7 @@ var ScreenShield = GObject.registerClass({ } // Re-check after async - conditions may have changed - let stillNeeded = this._state === State.HIDDEN && + let stillNeeded = (this._state === State.HIDDEN || this._activationPending) && this._settings.get_boolean('lock-enabled') && !Main.lockdownSettings.get_boolean('disable-lock-screen'); if (stillNeeded) { diff --git a/src/cinnamon-global-private.h b/src/cinnamon-global-private.h index a1015b969a..14112e0647 100644 --- a/src/cinnamon-global-private.h +++ b/src/cinnamon-global-private.h @@ -20,6 +20,7 @@ #endif #include +#include #include "cinnamon-enum-types.h" @@ -31,6 +32,16 @@ #include +typedef struct { + CinnamonGlobal *global; + guint32 timestamp; + MetaModalOptions options; + gint attempt; + gboolean tried_xdo; + CinnamonModalCallback callback; + gpointer user_data; +} ModalRetryData; + struct _CinnamonGlobal { GObject parent; @@ -64,6 +75,10 @@ struct _CinnamonGlobal { gboolean has_modal; guint notif_service_id; + + xdo_t *xdo; + guint modal_retry_source_id; + ModalRetryData *modal_retry_data; }; void _cinnamon_global_init (const char *first_property_name, diff --git a/src/cinnamon-global.c b/src/cinnamon-global.c index e379e74a2d..14d5daa64e 100644 --- a/src/cinnamon-global.c +++ b/src/cinnamon-global.c @@ -28,6 +28,14 @@ static CinnamonGlobal *the_object = NULL; +static gboolean _grab_debug = FALSE; + +#define debug_grab(fmt, ...) G_STMT_START { \ + if (_grab_debug) \ + g_message ("modal-retry: " fmt, ##__VA_ARGS__); \ +} G_STMT_END + + enum { PROP_0, @@ -244,6 +252,7 @@ cinnamon_global_init (CinnamonGlobal *global) g_mkdir_with_parents (global->userdatadir, 0700); global->settings = g_settings_new ("org.cinnamon"); + _grab_debug = g_settings_get_boolean (global->settings, "debug-screensaver"); setup_log_handler (global); @@ -268,6 +277,25 @@ static void cinnamon_global_finalize (GObject *object) { CinnamonGlobal *global = CINNAMON_GLOBAL (object); + + if (global->modal_retry_source_id != 0) + { + g_source_remove (global->modal_retry_source_id); + global->modal_retry_source_id = 0; + + if (global->modal_retry_data != NULL) + { + g_free (global->modal_retry_data); + global->modal_retry_data = NULL; + } + } + + if (global->xdo != NULL) + { + xdo_free (global->xdo); + global->xdo = NULL; + } + g_object_unref (global->js_context); g_object_unref (global->settings); @@ -991,7 +1019,12 @@ static void sync_input_region (CinnamonGlobal *global) { MetaDisplay *display = global->meta_display; - MetaX11Display *x11_display = meta_display_get_x11_display (display); + MetaX11Display *x11_display; + + if (meta_is_wayland_compositor ()) + return; + + x11_display = meta_display_get_x11_display (display); if (global->has_modal) meta_x11_display_set_stage_input_region (x11_display, None); @@ -1029,16 +1062,180 @@ cinnamon_global_begin_modal (CinnamonGlobal *global, return FALSE; global->has_modal = meta_plugin_begin_modal (global->plugin, options, timestamp); - if (!meta_is_wayland_compositor ()) - sync_input_region (global); + sync_input_region (global); return global->has_modal; } +#define MODAL_RETRY_INTERVAL_MS 1000 +#define MODAL_MAX_RETRIES 4 + +static gboolean +modal_try_grab (CinnamonGlobal *global, + MetaModalOptions options, + guint32 timestamp) +{ + if (!meta_display_get_compositor (global->meta_display) || global->has_modal) + return FALSE; + + return meta_plugin_begin_modal (global->plugin, options, timestamp); +} + +static void +modal_maybe_cancel_ui_grab (CinnamonGlobal *global) +{ + if (global->xdo == NULL) + { + debug_grab ("xdo context not available, skipping"); + return; + } + + debug_grab ("sending Escape key sequences"); + xdo_send_keysequence_window (global->xdo, CURRENTWINDOW, "Escape", 12000); + xdo_send_keysequence_window (global->xdo, CURRENTWINDOW, "Escape", 12000); +} + +static void +modal_retry_complete (ModalRetryData *data, + gboolean success) +{ + debug_grab ("complete, success=%s", success ? "true" : "false"); + data->global->modal_retry_source_id = 0; + data->global->modal_retry_data = NULL; + data->callback (data->global, success, data->user_data); + g_free (data); +} + +static gboolean +modal_retry_timeout (gpointer user_data) +{ + ModalRetryData *data = user_data; + CinnamonGlobal *global = data->global; + + data->attempt++; + + debug_grab ("attempt %d/%d (xdo_tried=%s)", + data->attempt, MODAL_MAX_RETRIES, + data->tried_xdo ? "true" : "false"); + + global->has_modal = modal_try_grab (global, data->options, data->timestamp); + + if (global->has_modal) + { + debug_grab ("grab succeeded on attempt %d", data->attempt); + sync_input_region (global); + modal_retry_complete (data, TRUE); + return G_SOURCE_REMOVE; + } + + if (data->attempt >= MODAL_MAX_RETRIES) + { + if (!data->tried_xdo) + { + debug_grab ("exhausted %d attempts, trying xdo escape + nuke focus", + MODAL_MAX_RETRIES); + data->tried_xdo = TRUE; + data->attempt = 0; + + if (global->xdo == NULL) + { + global->xdo = xdo_new (NULL); + if (global->xdo == NULL) + g_warning ("could not create xdo context"); + else + debug_grab ("xdo context created"); + } + + modal_maybe_cancel_ui_grab (global); + debug_grab ("unsetting input focus"); + meta_display_unset_input_focus (global->meta_display, CurrentTime); + + return G_SOURCE_CONTINUE; + } + + debug_grab ("exhausted all retries, giving up"); + modal_retry_complete (data, FALSE); + return G_SOURCE_REMOVE; + } + + return G_SOURCE_CONTINUE; +} + +/** + * cinnamon_global_begin_modal_with_retry: + * @global: a #CinnamonGlobal + * @timestamp: the X server timestamp of the event triggering the grab + * @options: #MetaModalOptions flags + * @callback: (scope async): function to call when the grab succeeds or fails + * @user_data: data to pass to @callback + * + * Like cinnamon_global_begin_modal(), but retries the grab asynchronously + * if the initial attempt fails. On X11, if retries fail, sends Escape key + * events via libxdo and clears X11 input focus to break any stuck grab + * (e.g. from a GTK popup menu), then retries again. + * + * Only one retry sequence can be active at a time. + */ +void +cinnamon_global_begin_modal_with_retry (CinnamonGlobal *global, + guint32 timestamp, + MetaModalOptions options, + CinnamonModalCallback callback, + gpointer user_data) +{ + ModalRetryData *data; + + g_return_if_fail (CINNAMON_IS_GLOBAL (global)); + g_return_if_fail (callback != NULL); + + debug_grab ("begin_modal_with_retry called"); + + if (global->modal_retry_source_id != 0) + { + debug_grab ("retry already in progress"); + callback (global, FALSE, user_data); + return; + } + + debug_grab ("trying initial grab"); + global->has_modal = modal_try_grab (global, options, timestamp); + + if (global->has_modal) + { + debug_grab ("initial grab succeeded"); + sync_input_region (global); + callback (global, TRUE, user_data); + return; + } + + if (meta_is_wayland_compositor ()) + { + debug_grab ("initial grab failed on Wayland, no retry available"); + callback (global, FALSE, user_data); + return; + } + + debug_grab ("initial grab failed on X11, starting retry sequence"); + + data = g_new0 (ModalRetryData, 1); + data->global = global; + data->timestamp = timestamp; + data->options = options; + data->callback = callback; + data->user_data = user_data; + global->modal_retry_data = data; + global->modal_retry_source_id = g_timeout_add (MODAL_RETRY_INTERVAL_MS, + modal_retry_timeout, + data); +} + /** * cinnamon_global_end_modal: * @global: a #CinnamonGlobal * - * Undoes the effect of cinnamon_global_begin_modal(). + * Undoes the effect of cinnamon_global_begin_modal(). If an async + * retry sequence from cinnamon_global_begin_modal_with_retry() is + * in progress, it is cancelled and the callback is invoked with + * %FALSE. */ void cinnamon_global_end_modal (CinnamonGlobal *global, @@ -1047,6 +1244,15 @@ cinnamon_global_end_modal (CinnamonGlobal *global, if (!meta_display_get_compositor (global->meta_display)) return; + if (global->modal_retry_data != NULL) + { + ModalRetryData *data = global->modal_retry_data; + debug_grab ("end_modal: cancelling in-progress retry"); + g_source_remove (global->modal_retry_source_id); + modal_retry_complete (data, FALSE); + return; + } + if (!global->has_modal) return; @@ -1063,8 +1269,7 @@ cinnamon_global_end_modal (CinnamonGlobal *global, meta_display_focus_default_window (global->meta_display, get_current_time_maybe_roundtrip (global)); - if (!meta_is_wayland_compositor ()) - sync_input_region (global); + sync_input_region (global); } static int diff --git a/src/cinnamon-global.h b/src/cinnamon-global.h index ad0e429cab..02d62d30ac 100644 --- a/src/cinnamon-global.h +++ b/src/cinnamon-global.h @@ -43,6 +43,17 @@ void cinnamon_global_dump_gjs_stack (CinnamonGlobal *global gboolean cinnamon_global_begin_modal (CinnamonGlobal *global, guint32 timestamp, MetaModalOptions options); + +typedef void (*CinnamonModalCallback) (CinnamonGlobal *global, + gboolean success, + gpointer user_data); + +void cinnamon_global_begin_modal_with_retry (CinnamonGlobal *global, + guint32 timestamp, + MetaModalOptions options, + CinnamonModalCallback callback, + gpointer user_data); + void cinnamon_global_end_modal (CinnamonGlobal *global, guint32 timestamp); diff --git a/src/meson.build b/src/meson.build index 925b67226e..548b94f8dc 100644 --- a/src/meson.build +++ b/src/meson.build @@ -110,6 +110,7 @@ libcinnamon_deps = [ polkit, st_dep, xapp, + xdo, xml, ]