Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion po/rounded-window-corners@fxgn.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand Down
9 changes: 9 additions & 0 deletions release.sh
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 23 additions & 2 deletions src/manager/event_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
computeWindowContentsOffset,
getRoundedCornersCfg,
getRoundedCornersEffect,
isChromiumWindow,
shouldEnableEffect,
unwrapActor,
updateShadowActorStyle,
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -185,7 +205,7 @@ export function onRestacked() {

export const onSizeChanged = refreshRoundedCorners;

export const onFocusChanged = refreshShadow;
export const onFocusChanged = refreshRoundedCorners;

export const onSettingsChanged = refreshAllRoundedCorners;

Expand Down Expand Up @@ -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;

Expand Down
126 changes: 114 additions & 12 deletions src/manager/event_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
},
Expand All @@ -76,13 +134,19 @@ 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);
}

disconnectAll();
}

let deferredRefreshId = 0;
const connections: {object: GObject.Object; id: number}[] = [];

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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;
});
}
});

Expand Down
Loading
Loading