Skip to content

Commit d1466a9

Browse files
feat: add sleep/wake resilience, wallpaper shuffle controls, and stability fixes
This commit introduces several features and robustness improvements to the Hanabi live wallpaper extension: ## Sleep/Wake Resilience - Add PrepareForSleep D-Bus listener on org.freedesktop.login1.Manager to gracefully handle system suspend and resume cycles. - SIGKILL the renderer process before suspend to avoid stale GStreamer pipelines that cause UI freezes on wake. - Automatically relaunch the renderer and force playback (setPlay) after a configurable delay on resume. - Introduce an _isSuspending guard to prevent autoPause from re-launching the renderer during the suspend window. - Suppress the playbackState mismatch handler during startup and wake to prevent the renderer from being force-paused before it is ready. ## Wallpaper Shuffle & Playback Controls - Add nextWallpaper and previousWallpaper D-Bus methods in dbus.js and renderer.js, enabling external scripts and panel controls to cycle through wallpapers. - Implement shuffle history tracking in the renderer so that previousWallpaper navigates back through recently played files. - Add a smooth fade transition between wallpapers using Clutter opacity animation, with a user-configurable fade-duration GSettings key. - Extend panelMenu.js with Previous Wallpaper button and a label showing the current wallpaper filename. ## DING Extension Compatibility - Guard the get_window_actors override in gnomeShellOverride.js so it is skipped when the DING (ding@rastersoft.com) extension is enabled, preventing 'replaceData.old_get_window_actors is undefined' errors. - Wrap _updateBackgrounds, _updateWorkspacesViews, and blur-my-shell hooks in _reloadBackgrounds with try-catch for defensive error handling. ## Disposed Actor Safety - Add an _isDestroyed flag to LiveWallpaper in wallpaper.js, set on the destroy signal, to guard against operations on disposed Clutter actors. - Cancel pending timeouts on destroy to prevent use-after-free crashes. - Wrap get_window_actors(false) calls in _getRenderer with try-catch. ## Preferences & Schema - Add fade-duration key to GSettings schema (default: 500 ms). - Add Fade Transition Duration SpinButton to the preferences UI. ## PlaybackState - Add suppressMismatch flag to playbackState.js to allow the extension to temporarily bypass the isPlayingChanged force-pause logic during startup and wake recovery windows. Files changed: src/dbus.js src/extension.js src/gnomeShellOverride.js src/panelMenu.js src/playbackState.js src/prefs.js src/renderer/renderer.js src/schemas/io.github.jeffshee.hanabi-extension.gschema.xml src/wallpaper.js
1 parent 3a9a906 commit d1466a9

9 files changed

Lines changed: 482 additions & 70 deletions

File tree

src/dbus.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export class RendererWrapper {
3434
<interface name="io.github.jeffshee.HanabiRenderer">
3535
<method name="setPlay"/>
3636
<method name="setPause"/>
37+
<method name="nextWallpaper"/>
38+
<method name="previousWallpaper"/>
3739
<property name="isPlaying" type="b" access="read"/>
3840
<signal name="isPlayingChanged">
3941
<arg name="isPlaying" type="b"/>
@@ -61,6 +63,22 @@ export class RendererWrapper {
6163
this._logger.warn(e);
6264
}
6365
}
66+
67+
async nextWallpaper() {
68+
try {
69+
await this.proxy.nextWallpaperAsync();
70+
} catch (e) {
71+
this._logger.warn(e);
72+
}
73+
}
74+
75+
async previousWallpaper() {
76+
try {
77+
await this.proxy.previousWallpaperAsync();
78+
} catch (e) {
79+
this._logger.warn(e);
80+
}
81+
}
6482
}
6583

6684
export class UPowerWrapper {

src/extension.js

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default class HanabiExtension extends Extension {
3434
this.launchRendererId = 0;
3535
this.currentProcess = null;
3636
this.reloadTime = 100;
37+
this._isSuspending = false;
3738

3839
/**
3940
* This is a safeguard measure for the case of Gnome Shell being relaunched
@@ -106,13 +107,135 @@ export default class HanabiExtension extends Extension {
106107
innerEnable() {
107108
this.override.enable();
108109
this.manager.enable();
109-
this.autoPause.enable();
110110

111111
this.isEnabled = true;
112112
if (this.launchRendererId)
113113
GLib.source_remove(this.launchRendererId);
114114

115+
// Launch renderer FIRST, then delay autoPause enable by 3 seconds.
116+
// This prevents the Pause on Maximize/Fullscreen module from pausing
117+
// the video at startup before the renderer has a chance to start playing.
118+
// (Session-restored maximized windows would otherwise trigger autoPause
119+
// while the renderer is still starting, and the PlaybackState mismatch
120+
// handler would immediately pause it.)
115121
this.launchRenderer();
122+
123+
this.playbackState.suppressMismatch = true;
124+
this._autoPauseDelayId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 5000, () => {
125+
this._autoPauseDelayId = null;
126+
this.playbackState.suppressMismatch = false;
127+
if (this.isEnabled)
128+
this.autoPause.enable();
129+
return false;
130+
});
131+
132+
// Watch for sleep/wake to restart the renderer on resume.
133+
// After sleep, the GStreamer pipeline may stall and take a long time to recover.
134+
// Killing and relaunching the renderer ensures a fresh pipeline on wake.
135+
this._setupSleepWatch();
136+
}
137+
138+
_setupSleepWatch() {
139+
if (this._sleepWatchId)
140+
return;
141+
try {
142+
this._loginProxy = Gio.DBusProxy.new_for_bus_sync(
143+
Gio.BusType.SYSTEM,
144+
Gio.DBusProxyFlags.NONE,
145+
null,
146+
'org.freedesktop.login1',
147+
'/org/freedesktop/login1',
148+
'org.freedesktop.login1.Manager',
149+
null
150+
);
151+
this._sleepWatchId = this._loginProxy.connect('g-signal', (_proxy, _sender, signalName, params) => {
152+
if (signalName === 'PrepareForSleep') {
153+
let goingToSleep = params.get_child_value(0).get_boolean();
154+
if (goingToSleep && this.isEnabled) {
155+
this._isSuspending = true;
156+
// Going to sleep - disable autoPause and force-kill renderer
157+
// BEFORE suspend to prevent GStreamer pipeline from freezing
158+
// the UI on wake.
159+
this.autoPause.disable();
160+
this._forceKillRenderer();
161+
} else if (!goingToSleep && this.isEnabled) {
162+
this._isSuspending = false;
163+
// Waking up - relaunch renderer with a fresh pipeline.
164+
this.playbackState.reset();
165+
if (this.launchRendererId) {
166+
GLib.source_remove(this.launchRendererId);
167+
this.launchRendererId = 0;
168+
}
169+
this.launchRenderer();
170+
171+
// Cancel any existing autoPause delay
172+
if (this._autoPauseDelayId) {
173+
GLib.source_remove(this._autoPauseDelayId);
174+
this._autoPauseDelayId = null;
175+
}
176+
this._cancelRendererWait();
177+
178+
// Suppress the mismatch handler so the renderer isn't
179+
// force-paused while starting up.
180+
this.playbackState.suppressMismatch = true;
181+
182+
// After 5 seconds, force-sync: directly tell renderer to
183+
// play via D-Bus (bypassing state machine which has no
184+
// transition from 'playing'), then re-enable autoPause.
185+
this._autoPauseDelayId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 5000, () => {
186+
this._autoPauseDelayId = null;
187+
this.playbackState.suppressMismatch = false;
188+
// Force renderer to play - reset() set state to 'playing'
189+
// but never sent a D-Bus command.
190+
this.playbackState._renderer.setPlay();
191+
if (this.isEnabled && !this._isSuspending)
192+
this.autoPause.enable();
193+
return false;
194+
});
195+
}
196+
}
197+
});
198+
} catch (e) {
199+
Logger.Logger.prototype.warn?.call(null, `Failed to setup sleep watch: ${e}`);
200+
}
201+
}
202+
203+
_teardownSleepWatch() {
204+
if (this._sleepWatchId && this._loginProxy) {
205+
this._loginProxy.disconnect(this._sleepWatchId);
206+
this._sleepWatchId = null;
207+
}
208+
this._loginProxy = null;
209+
}
210+
211+
/**
212+
* Force-kill the renderer process immediately using SIGKILL.
213+
* Used before sleep to ensure the GStreamer pipeline is completely dead
214+
* before the system suspends. Also cancels any pending relaunch timer
215+
* so the renderer doesn't restart before suspend completes.
216+
*/
217+
_forceKillRenderer() {
218+
// Cancel any pending relaunch so renderer doesn't restart before suspend
219+
if (this.launchRendererId) {
220+
GLib.source_remove(this.launchRendererId);
221+
this.launchRendererId = 0;
222+
}
223+
224+
if (this.currentProcess && this.currentProcess.subprocess) {
225+
this.currentProcess.cancellable.cancel();
226+
// SIGKILL (9) for immediate termination - no graceful shutdown
227+
this.currentProcess.subprocess.send_signal(9);
228+
}
229+
230+
this.currentProcess = null;
231+
this.manager.set_wayland_client(null);
232+
}
233+
234+
_cancelRendererWait() {
235+
if (this._rendererWaitCleanup) {
236+
this._rendererWaitCleanup();
237+
this._rendererWaitCleanup = null;
238+
}
116239
}
117240

118241
getPlaybackState() {
@@ -163,7 +286,7 @@ export default class HanabiExtension extends Extension {
163286
}
164287
this.currentProcess = null;
165288
this.manager.set_wayland_client(null);
166-
if (this.isEnabled) {
289+
if (this.isEnabled && !this._isSuspending) {
167290
if (this.launchRendererId)
168291
GLib.source_remove(this.launchRendererId);
169292

@@ -188,6 +311,16 @@ export default class HanabiExtension extends Extension {
188311
this.manager.disable();
189312
this.autoPause.disable();
190313

314+
// Cancel any pending autoPause delay
315+
if (this._autoPauseDelayId) {
316+
GLib.source_remove(this._autoPauseDelayId);
317+
this._autoPauseDelayId = null;
318+
}
319+
320+
this._cancelRendererWait();
321+
this._teardownSleepWatch();
322+
this._isSuspending = false;
323+
191324
this.isEnabled = false;
192325
this.killCurrentProcess();
193326
}

src/gnomeShellOverride.js

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,41 @@ export class GnomeShellOverride {
4848
this._wallpaperActors.forEach(actor => actor.destroy());
4949
this._wallpaperActors.clear();
5050

51-
Main.layoutManager._updateBackgrounds();
51+
try {
52+
Main.layoutManager._updateBackgrounds();
53+
} catch (e) {
54+
logger.warn(`Failed to update backgrounds: ${e}`);
55+
}
5256
// `Main.screenShield` is null if the user doesn't use Gnome Shell locking.
53-
if (Main.screenShield?._dialog?._updateBackgrounds != null)
54-
Main.screenShield._dialog._updateBackgrounds();
57+
try {
58+
if (Main.screenShield?._dialog?._updateBackgrounds != null)
59+
Main.screenShield._dialog._updateBackgrounds();
60+
} catch (e) {
61+
logger.warn(`Failed to update lockscreen backgrounds: ${e}`);
62+
}
5563

5664
/**
5765
* WorkspaceBackground has its own bgManager,
5866
* we have to recreate it to use our actors, so it can set radius to our actor.
5967
*/
60-
Main.overview._overview._controls._workspacesDisplay._updateWorkspacesViews();
68+
try {
69+
Main.overview._overview._controls._workspacesDisplay._updateWorkspacesViews();
70+
} catch (e) {
71+
logger.warn(`Failed to update workspace views: ${e}`);
72+
}
6173

6274
/**
6375
* Blur My Shell
6476
*/
65-
if (Main.extensionManager._enabledExtensions.includes('blur-my-shell@aunetx')) {
66-
// This will trigger the `update_backgrounds` method of overview, sceenshot and coverflow alt tab.
67-
Main.layoutManager.emit('monitors-changed');
68-
// This will trigger the `reset` method of panel.
69-
global.display.emit('workareas-changed');
77+
try {
78+
if (Main.extensionManager._enabledExtensions.includes('blur-my-shell@aunetx')) {
79+
// This will trigger the `update_backgrounds` method of overview, sceenshot and coverflow alt tab.
80+
Main.layoutManager.emit('monitors-changed');
81+
// This will trigger the `reset` method of panel.
82+
global.display.emit('workareas-changed');
83+
}
84+
} catch (e) {
85+
logger.warn(`Failed to notify blur-my-shell hooks: ${e}`);
7086
}
7187
}
7288

@@ -119,20 +135,25 @@ export class GnomeShellOverride {
119135

120136
// This removes the renderer from the window actor list.
121137
// Call `global.get_window_actors(false)` explicitly to bypass the override.
122-
this._injectionManager.overrideMethod(Shell.Global.prototype, 'get_window_actors',
123-
originalMethod => {
124-
// TODO: pass originalMethod to wallpaper instead
125-
return function (hideRenderer = true) {
126-
let windowActors = originalMethod.call(this);
127-
let result = hideRenderer
128-
? windowActors.filter(
129-
window => !window.meta_window.title?.includes(applicationId)
130-
)
131-
: windowActors;
132-
return result;
133-
};
134-
}
135-
);
138+
// NOTE: Desktop Icons NG (DING) also overrides this API and can conflict,
139+
// causing GNOME Shell errors/freezes on some systems.
140+
const isDingEnabled = Main.extensionManager?._enabledExtensions?.includes('ding@rastersoft.com');
141+
if (!isDingEnabled) {
142+
this._injectionManager.overrideMethod(Shell.Global.prototype, 'get_window_actors',
143+
originalMethod => {
144+
// TODO: pass originalMethod to wallpaper instead
145+
return function (hideRenderer = true) {
146+
let windowActors = originalMethod.call(this);
147+
let result = hideRenderer
148+
? windowActors.filter(
149+
window => !window.meta_window.title?.includes(applicationId)
150+
)
151+
: windowActors;
152+
return result;
153+
};
154+
}
155+
);
156+
}
136157

137158
// These remove the renderer's window preview in overview.
138159
this._injectionManager.overrideMethod(Workspace.Workspace.prototype, '_isOverviewWindow',

0 commit comments

Comments
 (0)