SYSTEM_BLOG
TIME: 00:00:00
STATUS: ONLINE
~/blog/gnome-extension-window-position-memory
$ cat gnome-extension-window-position-memory.md _
| 2025-12-08 | 8 min

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:

## Architecture Overview

The extension hooks into GNOME's window management system using GJS (GNOME JavaScript):

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

ComponentPurpose
Meta.WindowWindow object with position/size data
GLib.timeoutaddDebounced saves and delayed restores
Gio.FileJSON persistence to config directory
global.displaySignal source for window events

## Implementation Deep Dive

### Signal-Based Window Tracking

Instead of polling, we connect to GNOME's native signals:

JAVASCRIPT
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.get
windowactors();
for (const actor of actors) {
const window = actor.get
metawindow();
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:

JAVASCRIPT
const TRACKEDAPPS = [
    'gnome-terminal-server',
    'org.gnome.Terminal',
    'nautilus',
    'org.gnome.Nautilus',
    'gnome-text-editor',
    'org.gnome.TextEditor',
];

getAppId(window) {
const wmClass = window.get
wmclass();
if (wmClass && TRACKED
APPS.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:

JAVASCRIPT
setupWindow(window) {
    const appId = this.getAppId(window);
    if (!appId) return;

if (this.positions[appId]) {
const pos = this.positions[appId];

GLib.timeoutadd(GLib.PRIORITYDEFAULT, 200, () => {
if (window.get
maximized()) {
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:

JAVASCRIPT
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.SOURCE
REMOVE;
});
}

A 1-second debounce means positions save only after movement stops.

### JSON Storage

Positions persist to ~/.config/window-position-memory/positions.json:

JAVASCRIPT
savePositions() {
    const file = Gio.File.newforpath(this.configFile);
    const parent = file.getparent();

if (!parent.queryexists(null)) {
parent.makedirectorywithparents(null);
}

const contents = JSON.stringify(this.positions, null, 2);
file.replacecontents(
new TextEncoder().encode(contents),
null, false,
Gio.FileCreateFlags.REPLACE
DESTINATION,
null
);
}

The saved data looks like:

JSON
{
  "gnome-terminal-server": {
    "x": 100,
    "y": 50,
    "width": 1200,
    "height": 800
  },
  "nautilus": {
    "x": 1400,
    "y": 100,
    "width": 900,
    "height": 600
  }
}

## Installation

  1. Create the extension directory:
BASH
mkdir -p ~/.local/share/gnome-shell/extensions/window-position-memory@custom.local
  1. Add metadata.json:
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
}
  1. Add extension.js with the full implementation
  1. Enable the extension:
BASH
gnome-extensions enable window-position-memory@custom.local
  1. 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:

JAVASCRIPT
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:

JAVASCRIPT
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:

JAVASCRIPT
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:

JAVASCRIPT
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:

  1. Use signals, not polling - GNOME is event-driven
  2. Debounce everything - Disk I/O and redraws are expensive
  3. Clean up religiously - Memory leaks crash the shell
  4. Time your operations - Window state isn't immediately available
The full extension is ~200 lines of JavaScript. Sometimes the best solutions are the simplest ones.