diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e66b2acf --- /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/po/rounded-window-corners@fxgn.pot b/po/rounded-window-corners@fxgn.pot index 7a40f44c..600865f6 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/release.sh b/release.sh new file mode 100755 index 00000000..575b723b --- /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" diff --git a/src/manager/event_handlers.ts b/src/manager/event_handlers.ts index 6add91e5..0dd860cb 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, @@ -45,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); @@ -168,6 +171,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; + } } } @@ -185,7 +205,7 @@ export function onRestacked() { export const onSizeChanged = refreshRoundedCorners; -export const onFocusChanged = refreshShadow; +export const onFocusChanged = refreshRoundedCorners; export const onSettingsChanged = refreshAllRoundedCorners; @@ -291,6 +311,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 b29217fc..ebac0d49 100644 --- a/src/manager/event_manager.ts +++ b/src/manager/event_manager.ts @@ -7,10 +7,13 @@ 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'; import * as handlers from './event_handlers.js'; +import {isChromiumWindow} from './utils.js'; /** * The rounded corners effect has to perform some actions when differen events @@ -29,27 +32,82 @@ 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, '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); 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); } }, @@ -76,6 +134,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 +146,7 @@ export function disableEffect() { disconnectAll(); } +let deferredRefreshId = 0; const connections: {object: GObject.Object; id: number}[] = []; /** @@ -144,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; @@ -156,42 +226,74 @@ 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 // 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; + }); } }); // 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); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + if (actor.metaWindow) handlers.onSizeChanged(actor); + return GLib.SOURCE_REMOVE; + }); } }); // Window focus changed. - connect(actor.metaWindow, 'notify::appears-focused', () => { + 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(actor.metaWindow, 'workspace-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; + }); } }); diff --git a/src/manager/utils.ts b/src/manager/utils.ts index 0bab81b4..8ffe4d38 100644 --- a/src/manager/utils.ts +++ b/src/manager/utils.ts @@ -1,8 +1,12 @@ /** @file Provides various utility functions used withing signal handling code. */ +import type GLib from 'gi://GLib'; import type St from 'gi://St'; 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 Meta from 'gi://Meta'; @@ -90,11 +94,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; bounds.x1 += x1; @@ -155,19 +160,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); actor.style = `padding: ${SHADOW_PADDING}px;`; @@ -265,6 +268,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'; /** @@ -288,7 +308,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'; } } diff --git a/src/patch/add_shadow_in_overview.ts b/src/patch/add_shadow_in_overview.ts index 5199b0b9..da185301 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; // Setup the bounding box of the shadow actor. diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 50e36b27..bc377356 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; } /**