From 367c4c37895af4e306b36f6b47205b8f273c638b Mon Sep 17 00:00:00 2001 From: greg Date: Wed, 15 Apr 2026 10:04:31 +0200 Subject: [PATCH 1/5] fix: re-apply effects to Chromium windows after screen lock/unlock GNOME Shell disables and re-enables extensions during screen lock/unlock. When the extension re-enables, Chromium-based browsers (Brave, Chrome, Edge) may render stale surfaces for unfocused windows. The compositor skips repainting GLSL effects for these windows, resulting in scrambled borders and a doubled frame. Fix by briefly focusing each unfocused Chromium window after re-enable, which forces the compositor to repaint the GLSL effect. Also promote onFocusChanged from refreshShadow to refreshRoundedCorners so that any subsequent focus change recomputes shader bounds, not just the shadow. Fixes #124 --- src/manager/event_handlers.ts | 2 +- src/manager/event_manager.ts | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/manager/event_handlers.ts b/src/manager/event_handlers.ts index ef263dc4..f292a4c5 100644 --- a/src/manager/event_handlers.ts +++ b/src/manager/event_handlers.ts @@ -166,7 +166,7 @@ export function onRestacked(): void { export const onSizeChanged = refreshRoundedCorners; -export const onFocusChanged = refreshShadow; +export const onFocusChanged = refreshRoundedCorners; export const onSettingsChanged = refreshAllRoundedCorners; diff --git a/src/manager/event_manager.ts b/src/manager/event_manager.ts index 11788c43..2ee1fe67 100644 --- a/src/manager/event_manager.ts +++ b/src/manager/event_manager.ts @@ -8,6 +8,8 @@ import type Meta from 'gi://Meta'; import type Shell from 'gi://Shell'; import type {RoundedWindowActor} from '../utils/types.js'; +import GLib from 'gi://GLib'; + import {logDebug} from '../utils/log.js'; import {prefs} from '../utils/settings.js'; import * as handlers from './event_handlers.js'; @@ -29,10 +31,54 @@ export function enableEffect() { // Add the effect to all windows when the extension is enabled. const windowActors = global.get_window_actors(); logDebug(`Initial window count: ${windowActors.length}`); + for (const actor of windowActors) { applyEffectTo(actor); } + // When the extension is re-enabled after screen lock/unlock, + // Chromium-based browsers may render stale surfaces. The compositor + // skips repainting GLSL effects for unfocused windows, so briefly + // focusing each affected window forces a repaint and triggers our + // onFocusChanged handler which recomputes shader bounds. + if (windowActors.length > 0) { + deferredRefreshId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { + const focusedWin = global.display.get_focus_window(); + const chromiumWindows = global + .get_window_actors() + .map(a => a.metaWindow) + .filter( + (win): win is Meta.Window => + win != null && + win !== focusedWin && + isChromiumWindow(win), + ); + + if (chromiumWindows.length > 0) { + const timestamp = global.get_current_time(); + for (const [i, win] of chromiumWindows.entries()) { + GLib.timeout_add(GLib.PRIORITY_DEFAULT, i * 100, () => { + win.focus(timestamp); + return GLib.SOURCE_REMOVE; + }); + } + + // Restore focus to the originally focused window. + GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + chromiumWindows.length * 100, + () => { + focusedWin?.focus(global.get_current_time()); + return GLib.SOURCE_REMOVE; + }, + ); + } + + deferredRefreshId = 0; + return GLib.SOURCE_REMOVE; + }); + } + // Add the effect to new windows when they are opened. connect( global.display, @@ -74,6 +120,11 @@ export function enableEffect() { /** Disable the effect for all windows. */ export function disableEffect() { + if (deferredRefreshId) { + GLib.source_remove(deferredRefreshId); + deferredRefreshId = 0; + } + for (const actor of global.get_window_actors()) { removeEffectFrom(actor); } @@ -81,6 +132,23 @@ export function disableEffect() { disconnectAll(); } +const CHROMIUM_WM_CLASSES = [ + 'brave-browser', + 'chromium', + 'google-chrome', + 'microsoft-edge', +]; + +/** + * Check whether a window belongs to a Chromium-based browser. These apps + * render stale surfaces for unfocused windows after screen lock/unlock. + */ +function isChromiumWindow(win: Meta.Window): boolean { + const wmClass = win.get_wm_class_instance(); + return wmClass != null && CHROMIUM_WM_CLASSES.includes(wmClass); +} + +let deferredRefreshId = 0; const connections: {object: GObject.Object; id: number}[] = []; /** From 249a5320a0e8fbe4cca07d26369f7856abf79af1 Mon Sep 17 00:00:00 2001 From: greg Date: Thu, 16 Apr 2026 08:54:47 +0200 Subject: [PATCH 2/5] fix: guard against zero frame width in overview shadow allocation vfunc_allocate on the overview shadow clone can be called before the window has a valid frame rect, causing division by zero which produces NaN values in the allocation box and triggers Clutter assertion failures: Can't update stage views actor Shadow Actor (Overview) is on because it needs an allocation. clutter_actor_set_allocation_internal: assertion '!isnan (box->x1) && !isnan (box->x2) && !isnan (box->y1) && !isnan (box->y2)' failed --- src/patch/add_shadow_in_overview.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/patch/add_shadow_in_overview.ts b/src/patch/add_shadow_in_overview.ts index 40b14603..33486f08 100644 --- a/src/patch/add_shadow_in_overview.ts +++ b/src/patch/add_shadow_in_overview.ts @@ -113,11 +113,15 @@ const OverviewShadowActorClone = GObject.registerClass( return; } + const frameWidth = metaWindow.get_frame_rect().width; + if (frameWidth === 0) { + return; + } + // Scale the shadow by the same scale factor that the window preview // is scaled by. const containerScaleFactor = - windowContainerBox.get_width() / - metaWindow.get_frame_rect().width; + windowContainerBox.get_width() / frameWidth; const paddings = SHADOW_PADDING * containerScaleFactor * From 5503ea1c5e18812a3b4e5823928a2fc8749118eb Mon Sep 17 00:00:00 2001 From: greg Date: Thu, 16 Apr 2026 09:31:45 +0200 Subject: [PATCH 3/5] refactor: move isChromiumWindow to utils --- src/manager/event_manager.ts | 17 +---------------- src/manager/utils.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/manager/event_manager.ts b/src/manager/event_manager.ts index 2ee1fe67..924cda71 100644 --- a/src/manager/event_manager.ts +++ b/src/manager/event_manager.ts @@ -13,6 +13,7 @@ import GLib from 'gi://GLib'; import {logDebug} from '../utils/log.js'; import {prefs} from '../utils/settings.js'; import * as handlers from './event_handlers.js'; +import {isChromiumWindow} from './utils.js'; /** * The rounded corners effect has to perform some actions when differen events @@ -132,22 +133,6 @@ export function disableEffect() { disconnectAll(); } -const CHROMIUM_WM_CLASSES = [ - 'brave-browser', - 'chromium', - 'google-chrome', - 'microsoft-edge', -]; - -/** - * Check whether a window belongs to a Chromium-based browser. These apps - * render stale surfaces for unfocused windows after screen lock/unlock. - */ -function isChromiumWindow(win: Meta.Window): boolean { - const wmClass = win.get_wm_class_instance(); - return wmClass != null && CHROMIUM_WM_CLASSES.includes(wmClass); -} - let deferredRefreshId = 0; const connections: {object: GObject.Object; id: number}[] = []; diff --git a/src/manager/utils.ts b/src/manager/utils.ts index 79d0ed6e..339b2ef4 100644 --- a/src/manager/utils.ts +++ b/src/manager/utils.ts @@ -344,6 +344,25 @@ export function shouldEnableEffect( ); } +const CHROMIUM_WM_CLASSES = [ + 'brave-browser', + 'chromium', + 'google-chrome', + 'microsoft-edge', +]; + +/** + * Check whether a window belongs to a Chromium-based browser. These apps + * render stale surfaces for unfocused windows after screen lock/unlock. + * + * @param win - The window to check. + * @returns Whether the window belongs to a Chromium-based browser. + */ +export function isChromiumWindow(win: Meta.Window): boolean { + const wmClass = win.get_wm_class_instance(); + return wmClass != null && CHROMIUM_WM_CLASSES.includes(wmClass); +} + type AppType = 'LibAdwaita' | 'LibHandy' | 'Other'; /** From 407973d52d6291009e56eec86238a711781f531c Mon Sep 17 00:00:00 2001 From: greg Date: Fri, 17 Apr 2026 07:49:34 +0200 Subject: [PATCH 4/5] perf: cache deserialized pref values and simplify hot paths getPref() reads go through D-Bus and GLib Variant unpacking, which is expensive when done in hot paths that run per-frame (shader uniform updates) or per-event (focus/resize). This commit addresses the most frequent offenders. - Cache unpacked pref values in settings.ts. The cache is invalidated automatically by connecting to the GSettings 'changed' signal, so values stay correct across the preferences UI. This single change eliminates repeated unpacks for debug-mode (called from logDebug on every hot-path statement), blacklist/whitelist (read on every shouldEnableEffect call), keep-shadow-for-maximized-fullscreen, tweak-kitty-terminal, and the focused/unfocused shadow dictionaries. - Simplify updateShadowActorStyle to read global-rounded-corner-settings once and derive borderRadius/padding/smoothing from it, instead of four separate getPref calls (three via default parameters, one inside the function body). Caller-provided overrides are still honored. - Reorder the kitty check in computeBounds to compare wm_class first. The pref read is cheap now but the early wm_class comparison avoids it entirely for every non-kitty window, which is almost all of them. --- src/manager/utils.ts | 25 ++++++++++++------------- src/utils/settings.ts | 29 +++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/manager/utils.ts b/src/manager/utils.ts index 339b2ef4..142c6d40 100644 --- a/src/manager/utils.ts +++ b/src/manager/utils.ts @@ -151,11 +151,12 @@ export function computeBounds( }; // Kitty draws its window decoration by itself, so we need to manually - // clip its shadow and recompute the outer bounds for it. + // clip its shadow and recompute the outer bounds for it. Check wm_class + // first to avoid reading the pref for every non-kitty window. if ( - getPref('tweak-kitty-terminal') && + actor.metaWindow.get_wm_class_instance() === 'kitty' && actor.metaWindow.get_client_type() === Meta.WindowClientType.WAYLAND && - actor.metaWindow.get_wm_class_instance() === 'kitty' + getPref('tweak-kitty-terminal') ) { const [x1, y1, x2, y2] = APP_SHADOWS.kitty; const scale = windowScaleFactor(actor.metaWindow); @@ -224,19 +225,17 @@ export function computeShadowActorOffset( export function updateShadowActorStyle( win: Meta.Window, actor: St.Bin, - borderRadius = getPref('global-rounded-corner-settings').borderRadius, + borderRadius?: number, shadow = getPref('focused-shadow'), - padding = getPref('global-rounded-corner-settings').padding, + padding?: RoundedCornerSettings['padding'], ) { - const {left, right, top, bottom} = padding; - - // Increase border_radius when smoothing is on. - // Read global settings once to avoid repeated GSettings deserializations. - let adjustedBorderRadius = borderRadius; const globalCfg = getPref('global-rounded-corner-settings'); - if (globalCfg !== null) { - adjustedBorderRadius *= 1.0 + globalCfg.smoothing; - } + const effectiveBorderRadius = borderRadius ?? globalCfg.borderRadius; + const effectivePadding = padding ?? globalCfg.padding; + + const {left, right, top, bottom} = effectivePadding; + const adjustedBorderRadius = + effectiveBorderRadius * (1.0 + globalCfg.smoothing); // If there are two monitors with different scale factors, the scale of // the window may be different from the scale that has to be applied in diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 37fd50c3..660caffa 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -56,6 +56,15 @@ export const Schema = { /** The raw GSettings object for direct manipulation. */ export let prefs: Gio.Settings; +/** + * Cache of deserialized pref values. Reading a pref goes through D-Bus and + * Variant unpacking, so hot paths (per-frame shader updates, per-event + * refreshes) read the same keys many times per second. The cache is + * invalidated by connecting to the `changed` signal on init. + */ +const prefCache = new Map(); +let prefCacheChangedId = 0; + /** * Initialize the {@link prefs} object with existing GSettings. * @@ -64,22 +73,38 @@ export let prefs: Gio.Settings; export function initPrefs(gSettings: Gio.Settings) { resetOutdated(gSettings); prefs = gSettings; + prefCache.clear(); + prefCacheChangedId = prefs.connect('changed', (_, key) => { + prefCache.delete(key as SchemaKey); + }); } /** Delete the {@link prefs} object for garbage collection. */ export function uninitPrefs() { + if (prefCacheChangedId) { + prefs.disconnect(prefCacheChangedId); + prefCacheChangedId = 0; + } + prefCache.clear(); (prefs as Gio.Settings | null) = null; } /** * Get a preference from GSettings and convert it from a GLib Variant to a - * JavaScript type. + * JavaScript type. Values are cached and invalidated automatically when the + * underlying setting changes. * * @param key - The key of the preference to get. * @returns The value of the preference. */ export function getPref(key: K): Schema[K] { - return prefs.get_value(key).recursiveUnpack() as Schema[K]; + const cached = prefCache.get(key); + if (cached !== undefined) { + return cached as Schema[K]; + } + const value = prefs.get_value(key).recursiveUnpack() as Schema[K]; + prefCache.set(key, value); + return value; } /** From 8773ee99d9a580558ba838e97d4e6ef7ca56caa4 Mon Sep 17 00:00:00 2001 From: greg Date: Sat, 18 Apr 2026 09:57:40 +0200 Subject: [PATCH 5/5] fix: silence expected permission errors when reading /proc/pid/maps getAppType reads /proc//maps to detect LibHandy/LibAdwaita windows. For processes owned by another user (flatpak sandboxes, root-owned apps) the file is unreadable, producing a benign PERMISSION_DENIED that was logged with a full stack trace on every such window. Treat PERMISSION_DENIED and NOT_FOUND as expected and log them at debug level; keep logError for everything else. --- src/manager/utils.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/manager/utils.ts b/src/manager/utils.ts index 142c6d40..61ce89d3 100644 --- a/src/manager/utils.ts +++ b/src/manager/utils.ts @@ -5,6 +5,7 @@ import type {RoundedCornersEffect} from '../effect/rounded_corners_effect.js'; import type {Bounds, RoundedCornerSettings} from '../utils/types.js'; import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; import Meta from 'gi://Meta'; import St from 'gi://St'; @@ -385,7 +386,21 @@ function getAppType(win: Meta.Window): AppType { return 'Other'; } catch (e) { - logError(e); + // /proc//maps is unreadable for processes owned by another user + // (e.g. flatpak sandboxes), and can disappear if the process exits + // between get_pid() and the read. Both are expected and benign. + const isExpected = + e instanceof GLib.Error && + e.domain === Gio.io_error_quark() && + (e.code === Gio.IOErrorEnum.PERMISSION_DENIED || + e.code === Gio.IOErrorEnum.NOT_FOUND); + if (isExpected) { + logDebug( + `Could not read /proc/${win.get_pid()}/maps: ${(e as GLib.Error).message}`, + ); + } else { + logError(e); + } return 'Other'; } }