Building a GNOME Extension: Window Position Memory
# Building a GNOME Extension: Window Position Memory
Every time I reboot my Ubuntu machine, GNOME decides my terminal belongs in the center of the screen. Every. Single. Time. After the hundredth time dragging windows back to their rightful positions, I built an extension to fix it.
## The Problem
GNOME Shell doesn't remember window positions between sessions. Unlike Windows or macOS, closing your terminal means losing its carefully arranged position. For developers who rely on consistent workspace layouts, this is death by a thousand cuts.
"The best tools are the ones you forget exist—they just work."
## The Solution: window-position-memory@custom.local
A lightweight GNOME Shell extension that:
- >Tracks window positions and sizes in real-time
- >Persists positions to disk as JSON
- >Restores windows to saved positions on creation
- >Debounces saves to avoid disk thrashing
## Architecture Overview
The extension hooks into GNOME's window management system using GJS (GNOME JavaScript):
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Meta from 'gi://Meta';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';### Key Components
| Component | Purpose |
|---|---|
Meta.Window | Window object with position/size data |
GLib.timeoutadd | Debounced saves and delayed restores |
Gio.File | JSON persistence to config directory |
global.display | Signal source for window events |
## Implementation Deep Dive
### Signal-Based Window Tracking
Instead of polling, we connect to GNOME's native signals:
enable() {
this.loadPositions();
const display = global.display;
const windowCreatedId = display.connect('window-created', (, window) => {
this.onWindowCreated(window);
});
this.displaySignals.push(windowCreatedId);
// Handle existing windows on enable
const actors = global.getwindowactors();
for (const actor of actors) {
const window = actor.getmetawindow();
if (window) {
this.onWindowCreated(window);
}
}
}
The window-created signal fires for new windows, while getwindowactors() catches windows that existed before the extension loaded.
### Filtering Tracked Applications
Not every window needs position memory. We filter by WMCLASS:
const TRACKEDAPPS = [
'gnome-terminal-server',
'org.gnome.Terminal',
'nautilus',
'org.gnome.Nautilus',
'gnome-text-editor',
'org.gnome.TextEditor',
];
getAppId(window) {
const wmClass = window.getwmclass();
if (wmClass && TRACKEDAPPS.some(app =>
wmClass.toLowerCase().includes(app.toLowerCase()) ||
app.toLowerCase().includes(wmClass.toLowerCase())
)) {
return wmClass;
}
return null;
}
The bidirectional matching handles GNOME's inconsistent WMCLASS naming.
### Position Restoration with Timing
Window properties aren't immediately available on creation. We use staged timeouts:
setupWindow(window) {
const appId = this.getAppId(window);
if (!appId) return;
if (this.
positions[appId]) {
const pos = this.positions[appId];
GLib.timeout
add(GLib.PRIORITYDEFAULT, 200, () => {
if (window.getmaximized()) {
window.unmaximize(Meta.MaximizeFlags.BOTH);
}
window.moveresizeframe(
false,
pos.x, pos.y,
pos.width, pos.height
);
return GLib.SOURCEREMOVE;
});
}
// Track future changes...
}
The 200ms delay ensures the window manager has finished initial placement.
### Debounced Persistence
Saving on every pixel movement would hammer the disk. Debouncing aggregates rapid changes:
onWindowChanged(window, appId) {
if (this.saveTimeoutId) {
GLib.sourceremove(this.saveTimeoutId);
}
this.
saveTimeoutId = GLib.timeoutadd(GLib.PRIORITYDEFAULT, 1000, () => {
this.saveWindowPosition(window, appId);
this.savePositions();
this.saveTimeoutId = null;
return GLib.SOURCEREMOVE;
});
}A 1-second debounce means positions save only after movement stops.
### JSON Storage
Positions persist to ~/.config/window-position-memory/positions.json:
savePositions() {
const file = Gio.File.newforpath(this.configFile);
const parent = file.getparent();
if (!parent.query
exists(null)) {
parent.makedirectorywithparents(null);
}
const contents = JSON.stringify(this.
positions, null, 2);
file.replacecontents(
new TextEncoder().encode(contents),
null, false,
Gio.FileCreateFlags.REPLACEDESTINATION,
null
);
}The saved data looks like:
{
"gnome-terminal-server": {
"x": 100,
"y": 50,
"width": 1200,
"height": 800
},
"nautilus": {
"x": 1400,
"y": 100,
"width": 900,
"height": 600
}
}## Installation
- Create the extension directory:
mkdir -p ~/.local/share/gnome-shell/extensions/window-position-memory@custom.local- Add
metadata.json:
{
"uuid": "window-position-memory@custom.local",
"name": "Window Position Memory",
"description": "Remembers and restores window positions for tracked apps",
"shell-version": ["46"],
"version": 1
}- Add
extension.jswith the full implementation
- Enable the extension:
gnome-extensions enable window-position-memory@custom.local- Restart GNOME Shell (Alt+F2, type
r, press Enter) or log out and back in
## Gotchas and Lessons Learned
### The unmanaged Signal
Windows don't have a closed signal—they emit unmanaged when destroyed. This is your last chance to save state:
const unmanagedId = window.connect('unmanaged', () => {
this.saveWindowPosition(window, appId);
this.savePositions();
// Cleanup...
});### Maximized Window Handling
Never save positions of maximized windows—they'll restore incorrectly:
saveWindowPosition(window, appId) {
if (window.getmaximized()) {
return; // Skip maximized windows
}
// Save position...
}### Signal Cleanup is Critical
Failing to disconnect signals on disable() causes memory leaks and crashes:
disable() {
for (const [window, signals] of this.windowSignals) {
for (const signalId of signals) {
try {
window.disconnect(signalId);
} catch (e) {
// Window may already be destroyed
}
}
}
this.windowSignals.clear();
}## Extending the Extension
Want to track more apps? Add them to TRACKEDAPPS:
const TRACKEDAPPS = [
'gnome-terminal-server',
'nautilus',
'code', // VS Code
'firefox', // Firefox
'slack', // Slack
];## Conclusion
GNOME extensions operate in a privileged space with direct access to the window manager. The GJS bindings expose powerful APIs, but the documentation is sparse. The key lessons:
- Use signals, not polling - GNOME is event-driven
- Debounce everything - Disk I/O and redraws are expensive
- Clean up religiously - Memory leaks crash the shell
- Time your operations - Window state isn't immediately available