feat(extension): P1 — WorkspaceWarden lifecycle + position invariant

Replace the workspace-warden.ts stub with a real implementation matching
design §5:

- On enable: snapshot org.gnome.mutter::dynamic-workspaces and
  org.gnome.desktop.wm.preferences::num-workspaces, persist to
  ~/.local/state/dashward/workspace-warden.json, then disable dynamic
  mode and append a new workspace as the dashboard slot. num-workspaces
  is updated to match the new count so external observers stay in sync.
- Position invariant: connect to workspace-manager's workspaces-reordered,
  workspace-added, and workspace-removed signals; whenever the dashboard
  is no longer last, reorder it back. If something external removes the
  dashboard, append a replacement.
- Defensive guards: external changes to num-workspaces or
  dynamic-workspaces are clamped back; a `suppressGuard` flag avoids
  feedback loops between our own writes and our own signal handlers.
- On disable: remove the dashboard workspace, restore both gsettings,
  delete the state file.

Supporting infrastructure:

- util/logger.ts: console.log/warn/error wrappers with [Dashward] prefix.
- util/state-store.ts: load/save/clear JSON state under XDG_STATE_HOME.
- types/globals.d.ts: minimal Shell.Global declaration covering
  workspace_manager / display / get_current_time().

Build chain fixes uncovered while wiring P1:

- Replace placeholder @girs/* versions in extension/ and container/
  package.json with real published versions from npm (gnome-shell@50.0.0,
  meta-16@16.0.0-4.0.0, etc.). Add the required @girs/gio/glib/gobject
  packages so resolution actually succeeds.
- Set tsconfig `types` arrays to include each girs `/ambient` entry so
  the `gi://*` and `resource:///*` module specifiers resolve.
- Add `override` modifiers on Extension.enable/disable (required under
  the base tsconfig's noImplicitOverride).
- Fix workspace iteration to use get_workspace_by_index in a loop
  instead of the non-existent get_workspaces() — Meta 16 doesn't expose
  the bulk getter.

Verified: `pnpm -r build` and `pnpm -r exec tsc --noEmit` are both clean.
Functional verification against a real GNOME session is pending P2 — the
extension cannot be loaded yet because we haven't packaged it for the
test VM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-22 23:05:10 +01:00
parent 3bf3aa1989
commit 7b5539cf75
10 changed files with 1561 additions and 23 deletions

View File

@@ -9,8 +9,11 @@
"dev": "node esbuild.config.js --watch" "dev": "node esbuild.config.js --watch"
}, },
"devDependencies": { "devDependencies": {
"@girs/gjs": "4.0.0-next.1", "@girs/gjs": "^4.0.0",
"@girs/gtk-3.0": "3.24.0-next.1", "@girs/gtk-3.0": "^3.24.53-4.0.0",
"@girs/webkit2-4.1": "4.1.0-next.1" "@girs/webkit2-4.1": "^2.52.1-4.0.0",
"@girs/gio-2.0": "^2.88.0-4.0.0",
"@girs/glib-2.0": "^2.88.0-4.0.0",
"@girs/gobject-2.0": "^2.88.0-4.0.0"
} }
} }

View File

@@ -4,7 +4,14 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": ".", "rootDir": ".",
"noEmit": true, "noEmit": true,
"types": [] "types": [
"@girs/gjs",
"@girs/gio-2.0/ambient",
"@girs/glib-2.0/ambient",
"@girs/gobject-2.0/ambient",
"@girs/gtk-3.0/ambient",
"@girs/webkit2-4.1/ambient"
]
}, },
"include": ["src/**/*", "runtime/**/*"] "include": ["src/**/*", "runtime/**/*"]
} }

View File

@@ -9,8 +9,11 @@
"dev": "node esbuild.config.js --watch" "dev": "node esbuild.config.js --watch"
}, },
"devDependencies": { "devDependencies": {
"@girs/gnome-shell": "48.0.0-next.1", "@girs/gnome-shell": "^50.0.0",
"@girs/meta-16": "16.0.0-next.1", "@girs/meta-16": "^16.0.0-4.0.0",
"@girs/gtk-3.0": "3.24.0-next.1" "@girs/gtk-3.0": "^3.24.53-4.0.0",
"@girs/gio-2.0": "^2.88.0-4.0.0",
"@girs/glib-2.0": "^2.88.0-4.0.0",
"@girs/gobject-2.0": "^2.88.0-4.0.0"
} }
} }

View File

@@ -5,6 +5,7 @@ import { WindowGuard } from './window-guard.js';
import { ContainerSupervisor } from './container-supervisor.js'; import { ContainerSupervisor } from './container-supervisor.js';
import { EntryUX } from './entry-ux.js'; import { EntryUX } from './entry-ux.js';
import { DBusService } from './dbus-service.js'; import { DBusService } from './dbus-service.js';
import { log, error } from './util/logger.js';
export default class DashwardExtension extends Extension { export default class DashwardExtension extends Extension {
private warden?: WorkspaceWarden; private warden?: WorkspaceWarden;
@@ -13,22 +14,35 @@ export default class DashwardExtension extends Extension {
private entry?: EntryUX; private entry?: EntryUX;
private dbus?: DBusService; private dbus?: DBusService;
enable(): void { override enable(): void {
log('[Dashward] enable: P0 skeleton — components not wired yet'); log(`enable: ${this.metadata.uuid} v${this.metadata.version}`);
try {
this.warden = new WorkspaceWarden(); this.warden = new WorkspaceWarden();
this.guard = new WindowGuard(); } catch (e) {
this.container = new ContainerSupervisor(); error(`WorkspaceWarden init failed: ${String(e)}`);
this.entry = new EntryUX(); // Don't proceed if the warden didn't come up — everything else
this.dbus = new DBusService(); // depends on the dashboard workspace existing.
return;
} }
disable(): void { // P2+ components are still placeholders; left commented until each
log('[Dashward] disable'); // phase implements them so we don't pretend to be functional.
// this.guard = new WindowGuard();
// this.container = new ContainerSupervisor();
// this.entry = new EntryUX();
// this.dbus = new DBusService();
}
override disable(): void {
log('disable');
this.dbus?.dispose(); this.dbus?.dispose();
this.entry?.dispose(); this.entry?.dispose();
this.container?.dispose(); this.container?.dispose();
this.guard?.dispose(); this.guard?.dispose();
this.warden?.dispose(); this.warden?.dispose();
this.dbus = undefined; this.dbus = undefined;
this.entry = undefined; this.entry = undefined;
this.container = undefined; this.container = undefined;
@@ -36,5 +50,3 @@ export default class DashwardExtension extends Extension {
this.warden = undefined; this.warden = undefined;
} }
} }
declare function log(msg: string): void;

18
extension/src/types/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
// GNOME Shell injects a `global` singleton (Shell.Global) into the GJS
// global scope. The @girs/gnome-shell typings don't expose it as a
// resolvable module path, so we declare just the surface we use.
import type Meta from 'gi://Meta';
declare global {
const global: {
workspace_manager: Meta.WorkspaceManager;
display: Meta.Display;
get_current_time(): number;
};
// GJS provides a `log` builtin in addition to the Console API.
function log(msg: string): void;
}
export {};

View File

@@ -0,0 +1,13 @@
const PREFIX = '[Dashward]';
export function log(msg: string): void {
console.log(`${PREFIX} ${msg}`);
}
export function warn(msg: string): void {
console.warn(`${PREFIX} ${msg}`);
}
export function error(msg: string): void {
console.error(`${PREFIX} ${msg}`);
}

View File

@@ -0,0 +1,49 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import { warn } from './logger.js';
const STATE_DIR = GLib.build_filenamev([GLib.get_user_state_dir(), 'dashward']);
function ensureDir(): void {
const dir = Gio.File.new_for_path(STATE_DIR);
if (!dir.query_exists(null)) {
dir.make_directory_with_parents(null);
}
}
function pathFor(name: string): string {
return GLib.build_filenamev([STATE_DIR, `${name}.json`]);
}
export function loadState<T>(name: string): T | null {
const file = Gio.File.new_for_path(pathFor(name));
if (!file.query_exists(null)) return null;
try {
const [ok, contents] = file.load_contents(null);
if (!ok) return null;
const txt = new TextDecoder('utf-8').decode(contents);
return JSON.parse(txt) as T;
} catch (e) {
warn(`loadState(${name}) failed: ${String(e)}`);
return null;
}
}
export function saveState<T>(name: string, data: T): void {
ensureDir();
const bytes = new TextEncoder().encode(JSON.stringify(data, null, 2));
const file = Gio.File.new_for_path(pathFor(name));
file.replace_contents(bytes, null, false, Gio.FileCreateFlags.NONE, null);
}
export function clearState(name: string): void {
const file = Gio.File.new_for_path(pathFor(name));
if (file.query_exists(null)) {
try {
file.delete(null);
} catch (e) {
warn(`clearState(${name}) failed: ${String(e)}`);
}
}
}

View File

@@ -1,9 +1,226 @@
// Stub: see design §5 — workspace lifecycle. import Gio from 'gi://Gio';
// Responsibilities: snapshot gsettings, append the dashboard workspace, hold import type GObject from 'gi://GObject';
// position invariant on every workspace mutation, restore on disable. import type Meta from 'gi://Meta';
import { log, warn } from './util/logger.js';
import { loadState, saveState, clearState } from './util/state-store.js';
// Design §5: workspace lifecycle.
//
// Enable:
// 1. Snapshot org.gnome.mutter::dynamic-workspaces and
// org.gnome.desktop.wm.preferences::num-workspaces.
// 2. dynamic-workspaces = false (so an empty trailing slot won't be GC'd).
// 3. append_new_workspace at the current end; that's the dashboard slot.
// 4. num-workspaces = the new count (so user-visible setting matches reality).
//
// Steady state: dashboard.index() === n_workspaces - 1 after every mutation.
//
// Disable:
// Remove the dashboard workspace, restore both gsettings to the snapshot.
const SCHEMA_MUTTER = 'org.gnome.mutter';
const SCHEMA_WM_PREFS = 'org.gnome.desktop.wm.preferences';
const KEY_DYNAMIC = 'dynamic-workspaces';
const KEY_NUM = 'num-workspaces';
const STATE_NAME = 'workspace-warden';
interface SavedSettings {
dynamicWorkspaces: boolean;
numWorkspaces: number;
preEnableCount: number;
}
interface HandlerRef {
src: GObject.Object;
id: number;
}
export class WorkspaceWarden { export class WorkspaceWarden {
private readonly mutterSettings: Gio.Settings;
private readonly wmSettings: Gio.Settings;
private readonly handlers: HandlerRef[] = [];
private saved: SavedSettings | null = null;
private dashboardWs: Meta.Workspace | null = null;
private suppressGuard = false;
constructor() {
this.mutterSettings = new Gio.Settings({ schema_id: SCHEMA_MUTTER });
this.wmSettings = new Gio.Settings({ schema_id: SCHEMA_WM_PREFS });
this.enable();
}
getDashboardWorkspace(): Meta.Workspace | null {
return this.dashboardWs;
}
getDashboardIndex(): number {
return this.dashboardWs?.index() ?? -1;
}
dispose(): void { dispose(): void {
// restore snapshotted settings, remove dashboard workspace for (const h of this.handlers) {
try {
h.src.disconnect(h.id);
} catch (e) {
warn(`disconnect failed: ${String(e)}`);
}
}
this.handlers.length = 0;
this.removeDashboard();
this.restoreSettings();
clearState(STATE_NAME);
}
// -------------------------------------------------------------------------
private enable(): void {
const wm = global.workspace_manager;
const n = wm.get_n_workspaces();
// If a prior session crashed mid-enable, the saved state file is the
// only hint we have about what to restore — but we should not trust
// its preEnableCount as the new baseline. Snapshot fresh; the leftover
// file (if any) gets overwritten below.
const stale = loadState<SavedSettings>(STATE_NAME);
if (stale) {
warn(`stale state file found — last session may not have disabled cleanly`);
}
this.saved = {
dynamicWorkspaces: this.mutterSettings.get_boolean(KEY_DYNAMIC),
numWorkspaces: this.wmSettings.get_int(KEY_NUM),
preEnableCount: n,
};
saveState(STATE_NAME, this.saved);
// Order matters: turn off dynamic FIRST so mutter doesn't react to our
// append by GC'ing it back. Setting dynamic=false makes the count
// authoritative from num-workspaces; we'll update num-workspaces to
// match after the append.
this.suppressGuard = true;
this.mutterSettings.set_boolean(KEY_DYNAMIC, false);
this.dashboardWs = wm.append_new_workspace(false, global.get_current_time());
log(`appended dashboard workspace at index ${this.dashboardWs.index()} (n=${wm.get_n_workspaces()})`);
this.wmSettings.set_int(KEY_NUM, wm.get_n_workspaces());
this.suppressGuard = false;
this.connect(wm, 'workspaces-reordered', () => this.enforceLast());
this.connect(wm, 'workspace-added', () => this.handleWorkspaceAdded());
this.connect(wm, 'workspace-removed', () => this.handleWorkspaceRemoved());
this.connect(this.wmSettings, `changed::${KEY_NUM}`, () => this.handleNumWorkspacesChanged());
this.connect(this.mutterSettings, `changed::${KEY_DYNAMIC}`, () => this.handleDynamicChanged());
}
private connect(src: GObject.Object, signal: string, cb: (...args: unknown[]) => void): void {
const id = src.connect(signal, cb);
this.handlers.push({ src, id });
}
// -- invariants ----------------------------------------------------------
private enforceLast(): void {
if (this.suppressGuard || !this.dashboardWs) return;
const wm = global.workspace_manager;
const last = wm.get_n_workspaces() - 1;
const idx = this.dashboardWs.index();
if (idx !== last && idx >= 0) {
log(`reordering dashboard from ${idx} to ${last}`);
wm.reorder_workspace(this.dashboardWs, last);
}
}
private handleWorkspaceAdded(): void {
// Someone (us or another extension) added a workspace. If it landed
// after our dashboard, reorder dashboard back to last.
this.enforceLast();
}
private handleWorkspaceRemoved(): void {
if (this.suppressGuard || !this.dashboardWs) return;
const wm = global.workspace_manager;
let stillPresent = false;
for (let i = 0; i < wm.get_n_workspaces(); i++) {
if (wm.get_workspace_by_index(i) === this.dashboardWs) {
stillPresent = true;
break;
}
}
if (!stillPresent) {
warn('dashboard workspace was removed externally — recreating');
this.suppressGuard = true;
this.dashboardWs = wm.append_new_workspace(false, global.get_current_time());
this.wmSettings.set_int(KEY_NUM, wm.get_n_workspaces());
this.suppressGuard = false;
} else {
this.enforceLast();
}
}
private handleNumWorkspacesChanged(): void {
if (this.suppressGuard) return;
const wm = global.workspace_manager;
const desired = wm.get_n_workspaces();
const incoming = this.wmSettings.get_int(KEY_NUM);
// num-workspaces is the source of truth when dynamic=false; if someone
// bumps it down, mutter will trim from the end and kill our dashboard.
// Clamp it back up so reality (workspace_manager count) wins.
if (incoming !== desired) {
log(`external num-workspaces change (${incoming}) — clamping to ${desired}`);
this.suppressGuard = true;
this.wmSettings.set_int(KEY_NUM, desired);
this.suppressGuard = false;
}
}
private handleDynamicChanged(): void {
if (this.suppressGuard) return;
const enabled = this.mutterSettings.get_boolean(KEY_DYNAMIC);
if (enabled) {
warn('dynamic-workspaces was turned on externally — forcing back off');
this.suppressGuard = true;
this.mutterSettings.set_boolean(KEY_DYNAMIC, false);
this.suppressGuard = false;
}
}
// -- teardown ------------------------------------------------------------
private removeDashboard(): void {
if (!this.dashboardWs) return;
this.suppressGuard = true;
try {
global.workspace_manager.remove_workspace(this.dashboardWs, global.get_current_time());
log('removed dashboard workspace');
} catch (e) {
warn(`remove_workspace failed: ${String(e)}`);
}
this.dashboardWs = null;
this.suppressGuard = false;
}
private restoreSettings(): void {
if (!this.saved) return;
this.suppressGuard = true;
try {
this.wmSettings.set_int(KEY_NUM, this.saved.numWorkspaces);
this.mutterSettings.set_boolean(KEY_DYNAMIC, this.saved.dynamicWorkspaces);
log(`restored num-workspaces=${this.saved.numWorkspaces}, dynamic-workspaces=${this.saved.dynamicWorkspaces}`);
} catch (e) {
warn(`restoreSettings failed: ${String(e)}`);
}
this.suppressGuard = false;
this.saved = null;
} }
} }

View File

@@ -4,7 +4,14 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"noEmit": true, "noEmit": true,
"types": [] "types": [
"@girs/gnome-shell/ambient",
"@girs/meta-16/ambient",
"@girs/gio-2.0/ambient",
"@girs/glib-2.0/ambient",
"@girs/gobject-2.0/ambient",
"@girs/gtk-3.0/ambient"
]
}, },
"include": ["src/**/*"] "include": ["src/**/*"]
} }

1209
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff