From 2503b485cd2d0a33262375988fba1d64e9b49528 Mon Sep 17 00:00:00 2001 From: greg Date: Wed, 15 Apr 2026 10:04:31 +0200 Subject: [PATCH 1/9] 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 cf8f710..2d320af 100644 --- a/src/manager/event_handlers.ts +++ b/src/manager/event_handlers.ts @@ -186,7 +186,7 @@ export function onRestacked() { 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 b29217f..932a132 100644 --- a/src/manager/event_manager.ts +++ b/src/manager/event_manager.ts @@ -7,6 +7,8 @@ import type GObject from 'gi://GObject'; import type Meta from 'gi://Meta'; import type Shell from 'gi://Shell'; +import GLib from 'gi://GLib'; + import {logDebug} from '../utils/log.js'; import {prefs} from '../utils/settings.js'; import {hasMetaWindow, type RoundedWindowActor} from '../utils/types.js'; @@ -29,12 +31,56 @@ 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) { if (hasMetaWindow(actor)) { 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, @@ -76,6 +122,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); } @@ -83,6 +134,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 8af15df1be5399fbefd8e6d966271ebe525bf88b Mon Sep 17 00:00:00 2001 From: greg Date: Thu, 16 Apr 2026 08:54:47 +0200 Subject: [PATCH 2/9] 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 da16c94..6210f83 100644 --- a/src/patch/add_shadow_in_overview.ts +++ b/src/patch/add_shadow_in_overview.ts @@ -117,11 +117,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 390b018e4072f9d8b926da9de7bda9f21c036185 Mon Sep 17 00:00:00 2001 From: greg Date: Thu, 16 Apr 2026 09:31:45 +0200 Subject: [PATCH 3/9] refactor: move isChromiumWindow to utils --- src/manager/event_manager.ts | 17 +---------------- src/manager/utils.ts | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/manager/event_manager.ts b/src/manager/event_manager.ts index 932a132..38ab52a 100644 --- a/src/manager/event_manager.ts +++ b/src/manager/event_manager.ts @@ -13,6 +13,7 @@ import {logDebug} from '../utils/log.js'; import {prefs} from '../utils/settings.js'; import {hasMetaWindow, type RoundedWindowActor} from '../utils/types.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 @@ -134,22 +135,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 77a7cb8..6b24fa8 100644 --- a/src/manager/utils.ts +++ b/src/manager/utils.ts @@ -2,7 +2,10 @@ import type Clutter from 'gi://Clutter'; import type {RoundedCornersEffect} from '../effect/rounded_corners_effect.js'; -import type {RoundedWindowActor} from '../utils/types.js'; +import type { + RoundedCornerSettings, + RoundedWindowActor, +} from '../utils/types.js'; import Gio from 'gi://Gio'; import Meta from 'gi://Meta'; @@ -341,6 +344,23 @@ export async function shouldEnableEffect( ); } +// Brave origin apps (PWAs, site-specific browsers) use 'brave-origin' instead +// of 'brave-browser', so we match the prefix rather than the full class name. +const CHROMIUM_WM_CLASS_PATTERN = + /^(brave-(browser|origin)|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_CLASS_PATTERN.test(wmClass); +} + type AppType = 'LibAdwaita' | 'LibHandy' | 'Other'; /** From 4b3dd2c1526746e17130cd40e84c6ca1fbaffbe7 Mon Sep 17 00:00:00 2001 From: greg Date: Fri, 17 Apr 2026 07:49:34 +0200 Subject: [PATCH 4/9] 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 | 31 ++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/manager/utils.ts b/src/manager/utils.ts index 6b24fa8..db56ed4 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 50e36b2..bc37735 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -54,6 +54,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. * @@ -62,22 +71,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) { - return prefs.get_value(key).recursiveUnpack() as Schema[K]; +export function getPref(key: K): 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 29f8971dc8cf14b571ef2a7ca71d23ec3df61722 Mon Sep 17 00:00:00 2001 From: greg Date: Sat, 18 Apr 2026 09:57:40 +0200 Subject: [PATCH 5/9] 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 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/manager/utils.ts b/src/manager/utils.ts index db56ed4..f68cc92 100644 --- a/src/manager/utils.ts +++ b/src/manager/utils.ts @@ -1,6 +1,7 @@ /** @file Provides various utility functions used withing signal handling code. */ import type Clutter from 'gi://Clutter'; +import type GLib from 'gi://GLib'; import type {RoundedCornersEffect} from '../effect/rounded_corners_effect.js'; import type { RoundedCornerSettings, @@ -383,7 +384,13 @@ async function getAppType(win: Meta.Window) { return 'Other'; } catch (e) { - logError(e); + // /proc//maps can fail for several expected reasons: the process + // is owned by another user (PERMISSION_DENIED), it exited between + // get_pid() and the read (NOT_FOUND or "No such process"), or any + // other transient I/O condition. All are benign — log at debug level. + logDebug( + `Could not read /proc/${win.get_pid()}/maps: ${(e as GLib.Error).message}`, + ); return 'Other'; } } From 9b3c201bdb636fc2e513b31eee6337a78a8536be Mon Sep 17 00:00:00 2001 From: greg Date: Wed, 3 Jun 2026 10:42:44 +0200 Subject: [PATCH 6/9] fix: guard against null metaWindow to prevent clutter_actor_node_new crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mutter 50.2 (Wayland-only), actor.metaWindow can be null during window actor lifecycle transitions. Previously, applyEffectTo connected notify::size and size-changed before reaching the actor.metaWindow signal connections. When metaWindow was null, the TypeError aborted the function mid-way, leaving size signals connected but the effect never added. On the next notify::size (fired during Clutter layout), onAddEffect was called while the actor was mid-paint, corrupting the effect's actor pointer and causing clutter_actor_node_new(NULL) → SIGABRT. Fix by guarding actor.metaWindow before connecting any signals in applyEffectTo so the function either completes fully or returns early with no signals connected. Add matching null guards to onAddEffect and refreshRoundedCorners. Also guard actor from get_compositor_private() in the window-created handler and re-fetch it fresh in the wm-class callback. Co-Authored-By: Claude Sonnet 4.6 --- src/manager/event_handlers.ts | 5 ++++- src/manager/event_manager.ts | 25 ++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/manager/event_handlers.ts b/src/manager/event_handlers.ts index 2d320af..c75af7e 100644 --- a/src/manager/event_handlers.ts +++ b/src/manager/event_handlers.ts @@ -46,8 +46,10 @@ function withActorLock( export function onAddEffect(actor: RoundedWindowActor) { return withActorLock(actor, async () => { - logDebug(`Adding effect to ${actor?.metaWindow.title}`); const win = actor.metaWindow; + if (!win) return; + + logDebug(`Adding effect to ${win.title}`); // Skip windows that already have the effect to prevent a memory leak const shouldHaveEffect = await shouldEnableEffect(win); @@ -292,6 +294,7 @@ function refreshRoundedCorners(actor: RoundedWindowActor) { */ function updateEffect(actor: RoundedWindowActor) { const win = actor.metaWindow; + if (!win) return; const windowInfo = actor.rwcCustomData; if (!windowInfo) return; diff --git a/src/manager/event_manager.ts b/src/manager/event_manager.ts index 38ab52a..e29d139 100644 --- a/src/manager/event_manager.ts +++ b/src/manager/event_manager.ts @@ -87,13 +87,18 @@ export function enableEffect() { global.display, 'window-created', (_: Meta.Display, win: Meta.Window) => { - const actor: RoundedWindowActor = win.get_compositor_private(); + const actor = + win.get_compositor_private() as Meta.WindowActor | null; + if (!actor) return; // If wm_class_instance of Meta.Window is null, wait for it to be // set before applying the effect. if (win?.get_wm_class_instance() === null) { const notifyId = win.connect('notify::wm-class', () => { - applyEffectTo(actor); + // Re-fetch: compositor may have assigned a new actor by now. + const freshActor = + win.get_compositor_private() as Meta.WindowActor | null; + if (freshActor) applyEffectTo(freshActor); win.disconnect(notifyId); }); } else { @@ -209,6 +214,16 @@ function applyEffectTo(actor: RoundedWindowActor) { return; } + // On mutter 50.2 (Wayland-only), metaWindow can be null during actor + // lifecycle transitions (e.g. when first-child fires during destruction). + // Guard here so we never end up with size signals connected but no effect + // added — that broken partial state is what triggers the C-level + // clutter_actor_node_new assertion when notify::size fires mid-paint. + const metaWin = actor.metaWindow; + if (!metaWin) { + return; + } + // Window resized. // // The signal has to be connected both to the actor and the texture. Why is @@ -228,21 +243,21 @@ function applyEffectTo(actor: RoundedWindowActor) { // Get notified about fullscreen explicitly, since a window must not change in // size to go fullscreen - connect(actor.metaWindow, 'notify::fullscreen', () => { + connect(metaWin, 'notify::fullscreen', () => { if (actor.metaWindow) { handlers.onSizeChanged(actor); } }); // Window focus changed. - connect(actor.metaWindow, 'notify::appears-focused', () => { + connect(metaWin, 'notify::appears-focused', () => { if (actor.metaWindow) { handlers.onFocusChanged(actor); } }); // Workspace or monitor of the window changed. - connect(actor.metaWindow, 'workspace-changed', () => { + connect(metaWin, 'workspace-changed', () => { if (actor.metaWindow) { handlers.onFocusChanged(actor); } From 3f31e0d25d0510bf7b3b56f33137dfe9fcaf9e60 Mon Sep 17 00:00:00 2001 From: greg Date: Tue, 9 Jun 2026 10:52:59 +0200 Subject: [PATCH 7/9] fix: defer signal callbacks to prevent effect add/remove mid-paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mutter 50.2, Clutter can emit notify::size and size-changed during a layout pass that runs inside a paint frame — specifically during meta_window_actor_paint_to_content, which GNOME Shell calls to capture a window snapshot at the start of the maximize animation. If keepRoundedCorners.maximized is false (the default), the notify::size callback triggered refreshRoundedCorners → onRemoveEffect for the newly- maximized window. onRemoveEffect called actor.remove_effect_by_name() while CLUTTER_ACTOR_IN_PAINT was set, which corrupted the RoundedCornersEffect's actor pointer via clutter_actor_meta_set_actor. The subsequent clutter_actor_continue_paint call then hit clutter_actor_node_new(NULL) → SIGABRT. The same class of bug exists in the notify::first-child and notify::wm-class deferred callbacks: both call applyEffectTo synchronously, which can invoke onAddEffect (add_effect_with_name) while the actor is mid-paint if the Wayland event or layout notification fires inside a paint frame. Fix by wrapping all seven signal callbacks in applyEffectTo, and the two deferred-apply callbacks in enableEffect, with GLib.idle_add at PRIORITY_DEFAULT_IDLE. This pushes effect add/remove out of any active paint frame into the next GLib main loop iteration, where it is safe to modify the actor's effect list. The previous fix (4af9a02) addressed the null-metaWindow path that led to the same assertion. This commit addresses the remaining path where metaWindow is valid but effect mutations still race with an active paint. Co-Authored-By: Claude Sonnet 4.6 --- src/manager/event_manager.ts | 56 +++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/manager/event_manager.ts b/src/manager/event_manager.ts index e29d139..ebac0d4 100644 --- a/src/manager/event_manager.ts +++ b/src/manager/event_manager.ts @@ -95,13 +95,19 @@ export function enableEffect() { // set before applying the effect. if (win?.get_wm_class_instance() === null) { const notifyId = win.connect('notify::wm-class', () => { - // Re-fetch: compositor may have assigned a new actor by now. - const freshActor = - win.get_compositor_private() as Meta.WindowActor | null; - if (freshActor) applyEffectTo(freshActor); win.disconnect(notifyId); + // Defer: notify::wm-class can fire while Wayland protocol + // messages are processed during a paint frame. Re-fetch the + // actor inside the idle so we always use a fresh reference. + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + const freshActor = + win.get_compositor_private() as Meta.WindowActor | null; + if (freshActor && hasMetaWindow(freshActor)) + applyEffectTo(freshActor); + return GLib.SOURCE_REMOVE; + }); }); - } else { + } else if (hasMetaWindow(actor)) { applyEffectTo(actor); } }, @@ -202,8 +208,14 @@ function applyEffectTo(actor: RoundedWindowActor) { // ready before applying the effect. if (!actor.firstChild) { const id = actor.connect('notify::first-child', () => { - applyEffectTo(actor); actor.disconnect(id); + // Defer: notify::first-child can fire during a Clutter layout pass + // inside a paint frame. Adding effects mid-paint corrupts the + // effect's actor pointer and triggers clutter_actor_node_new(NULL). + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + applyEffectTo(actor); + return GLib.SOURCE_REMOVE; + }); }); return; @@ -230,14 +242,27 @@ function applyEffectTo(actor: RoundedWindowActor) { // that? I have no idea. But without that, weird bugs can happen. For // example, when using Dash to Dock, all opened windows will be invisible // *unless they are pinned in the dock*. So yeah, GNOME is magic. + // All signal callbacks that can trigger onAddEffect or onRemoveEffect are + // deferred with GLib.idle_add. Clutter can emit notify::size and + // size-changed during a layout pass that runs inside a paint frame (e.g. + // during meta_window_actor_paint_to_content for maximize animations). + // Adding or removing an effect mid-paint sets the effect's actor pointer + // while CLUTTER_ACTOR_IN_PAINT is set, which corrupts internal state and + // causes clutter_actor_node_new(NULL) → SIGABRT. connect(actor, 'notify::size', () => { if (actor.metaWindow) { - handlers.onSizeChanged(actor); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + if (actor.metaWindow) handlers.onSizeChanged(actor); + return GLib.SOURCE_REMOVE; + }); } }); connect(texture, 'size-changed', () => { if (actor.metaWindow) { - handlers.onSizeChanged(actor); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + if (actor.metaWindow) handlers.onSizeChanged(actor); + return GLib.SOURCE_REMOVE; + }); } }); @@ -245,21 +270,30 @@ function applyEffectTo(actor: RoundedWindowActor) { // size to go fullscreen connect(metaWin, 'notify::fullscreen', () => { if (actor.metaWindow) { - handlers.onSizeChanged(actor); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + if (actor.metaWindow) handlers.onSizeChanged(actor); + return GLib.SOURCE_REMOVE; + }); } }); // Window focus changed. connect(metaWin, 'notify::appears-focused', () => { if (actor.metaWindow) { - handlers.onFocusChanged(actor); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + if (actor.metaWindow) handlers.onFocusChanged(actor); + return GLib.SOURCE_REMOVE; + }); } }); // Workspace or monitor of the window changed. connect(metaWin, 'workspace-changed', () => { if (actor.metaWindow) { - handlers.onFocusChanged(actor); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + if (actor.metaWindow) handlers.onFocusChanged(actor); + return GLib.SOURCE_REMOVE; + }); } }); From eba3c9d2b1615077891f6672caf5759ad938c28e Mon Sep 17 00:00:00 2001 From: greg Date: Mon, 29 Jun 2026 15:18:12 +0200 Subject: [PATCH 8/9] fix: prevent Chromium corner glitch on restore after app-initiated minimize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium-based browsers use xdg_toplevel.set_minimized for their own title bar minimize, which causes Wayland surface staleness. On restore, the stale surface makes the GLSL rounded corner shader render with wrong bounds, producing a visible glitch until focus fires and self-corrects. Fix by scheduling a refreshRoundedCorners call 250ms after unminimize for Chromium windows, giving the Wayland surface time to deliver a fresh frame before the shader bounds are recomputed. Compositor-initiated minimizes (Super+H/D) do not cause surface staleness and are unaffected. The overview thumbnail continues to show the stale Chromium surface while minimized — this is a known Chromium/Wayland limitation outside the extension's control. Co-Authored-By: Claude Sonnet 4.6 --- po/rounded-window-corners@fxgn.pot | 2 +- src/manager/event_handlers.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/po/rounded-window-corners@fxgn.pot b/po/rounded-window-corners@fxgn.pot index 7a40f44..600865f 100644 --- a/po/rounded-window-corners@fxgn.pot +++ b/po/rounded-window-corners@fxgn.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-13 15:46+0200\n" +"POT-Creation-Date: 2026-06-30 08:35+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/src/manager/event_handlers.ts b/src/manager/event_handlers.ts index c75af7e..2dc9ad5 100644 --- a/src/manager/event_handlers.ts +++ b/src/manager/event_handlers.ts @@ -24,6 +24,7 @@ import { computeWindowContentsOffset, getRoundedCornersCfg, getRoundedCornersEffect, + isChromiumWindow, shouldEnableEffect, unwrapActor, updateShadowActorStyle, @@ -171,6 +172,23 @@ export function onUnminimize(actor: RoundedWindowActor) { source.disconnect(id); } }); + } else if (roundedCornersEffect) { + const win = actor.metaWindow; + if (win && isChromiumWindow(win)) { + // Chromium's Wayland surface takes ~250ms to deliver a fresh frame + // after restore. Refreshing immediately uses a stale surface and + // produces glitched corners; the delayed call picks up the settled + // surface without blocking any intermediate refreshes. + const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { + if (actor.rwcCustomData) + actor.rwcCustomData.unminimizedTimeoutId = 0; + if (actor.metaWindow && getRoundedCornersEffect(actor)) + refreshRoundedCorners(actor); + return GLib.SOURCE_REMOVE; + }); + if (actor.rwcCustomData) + actor.rwcCustomData.unminimizedTimeoutId = id; + } } } From 4329c20293d5341f1f884a3f937db8be3b6ffcaa Mon Sep 17 00:00:00 2001 From: greg Date: Sun, 19 Jul 2026 08:05:57 +0200 Subject: [PATCH 9/9] ci: add tag-based release workflow --- .github/workflows/release.yml | 37 +++++++++++++++++++++++++++++++++++ release.sh | 9 +++++++++ 2 files changed, 46 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100755 release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e66b2ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release the extension + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Build and publish a release + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: Set up node.js + uses: actions/setup-node@v4 + with: + node-version: latest + cache: 'npm' + + - name: Set up just + uses: extractions/setup-just@v2 + + - name: Install gettext + run: sudo apt install gettext + + - name: Pack the extension + run: just pack + + - name: Publish the release + uses: softprops/action-gh-release@v2 + with: + files: rounded-window-corners@fxgn.shell-extension.zip diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..575b723 --- /dev/null +++ b/release.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -eo pipefail -u + +TAG="v$(date +%Y%m%d).$(git rev-parse --short=7 HEAD)" + +git tag "$TAG" +git push origin "$TAG" + +echo "$TAG"