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:
@@ -9,8 +9,11 @@
|
||||
"dev": "node esbuild.config.js --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@girs/gnome-shell": "48.0.0-next.1",
|
||||
"@girs/meta-16": "16.0.0-next.1",
|
||||
"@girs/gtk-3.0": "3.24.0-next.1"
|
||||
"@girs/gnome-shell": "^50.0.0",
|
||||
"@girs/meta-16": "^16.0.0-4.0.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WindowGuard } from './window-guard.js';
|
||||
import { ContainerSupervisor } from './container-supervisor.js';
|
||||
import { EntryUX } from './entry-ux.js';
|
||||
import { DBusService } from './dbus-service.js';
|
||||
import { log, error } from './util/logger.js';
|
||||
|
||||
export default class DashwardExtension extends Extension {
|
||||
private warden?: WorkspaceWarden;
|
||||
@@ -13,22 +14,35 @@ export default class DashwardExtension extends Extension {
|
||||
private entry?: EntryUX;
|
||||
private dbus?: DBusService;
|
||||
|
||||
enable(): void {
|
||||
log('[Dashward] enable: P0 skeleton — components not wired yet');
|
||||
this.warden = new WorkspaceWarden();
|
||||
this.guard = new WindowGuard();
|
||||
this.container = new ContainerSupervisor();
|
||||
this.entry = new EntryUX();
|
||||
this.dbus = new DBusService();
|
||||
override enable(): void {
|
||||
log(`enable: ${this.metadata.uuid} v${this.metadata.version}`);
|
||||
|
||||
try {
|
||||
this.warden = new WorkspaceWarden();
|
||||
} catch (e) {
|
||||
error(`WorkspaceWarden init failed: ${String(e)}`);
|
||||
// Don't proceed if the warden didn't come up — everything else
|
||||
// depends on the dashboard workspace existing.
|
||||
return;
|
||||
}
|
||||
|
||||
// P2+ components are still placeholders; left commented until each
|
||||
// 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();
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
log('[Dashward] disable');
|
||||
override disable(): void {
|
||||
log('disable');
|
||||
|
||||
this.dbus?.dispose();
|
||||
this.entry?.dispose();
|
||||
this.container?.dispose();
|
||||
this.guard?.dispose();
|
||||
this.warden?.dispose();
|
||||
|
||||
this.dbus = undefined;
|
||||
this.entry = undefined;
|
||||
this.container = undefined;
|
||||
@@ -36,5 +50,3 @@ export default class DashwardExtension extends Extension {
|
||||
this.warden = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
declare function log(msg: string): void;
|
||||
|
||||
18
extension/src/types/globals.d.ts
vendored
Normal file
18
extension/src/types/globals.d.ts
vendored
Normal 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 {};
|
||||
13
extension/src/util/logger.ts
Normal file
13
extension/src/util/logger.ts
Normal 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}`);
|
||||
}
|
||||
49
extension/src/util/state-store.ts
Normal file
49
extension/src/util/state-store.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,226 @@
|
||||
// Stub: see design §5 — workspace lifecycle.
|
||||
// Responsibilities: snapshot gsettings, append the dashboard workspace, hold
|
||||
// position invariant on every workspace mutation, restore on disable.
|
||||
import Gio from 'gi://Gio';
|
||||
import type GObject from 'gi://GObject';
|
||||
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 {
|
||||
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 {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"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/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user