feat: P3 — WebKit container process pinned to dashboard

Dashward now actually renders content on the dashboard workspace. The
container is a separate gjs subprocess (so a WebKit crash can't take
down gnome-shell), and the extension pins its window to the dashboard
slot via the WindowGuard whitelist.

container/src/main.ts: tiny GJS bootstrap that creates a borderless
fullscreen Gtk.Window with a WebKit2 WebView and loads dashboard.html
from a runtime directory passed in via argv. `GLib.set_prgname` happens
before any GTK init so Wayland's xdg-shell app_id matches
`top.hangmanlab.dashward.container` -- that's the wm_class fingerprint
the extension matches against.

extension/src/container-supervisor.ts: spawn the container via
Gio.Subprocess; pump its stdout/stderr into journal under `[container
stdout|stderr]` tags so we can diagnose WebKit crashes without
attaching; watch display::window-created for the app_id match; on
arrival, whitelist with WindowGuard before moving to dashboard (so the
move's window-added doesn't bounce); make_fullscreen; clear the cached
ref on the window's `unmanaged` signal. Dispose SIGTERMs the
subprocess. P3 explicitly skips auto-restart / exponential backoff and
DBus signaling -- those land in P4.

container/runtime/runtime.ts + styles.css: a "Dashward" placeholder
card on the 12-column grid so the dashboard workspace is visually
distinct from a regular workspace; widget mounting / edit mode is P5+.

Verified on the ubuntu2504-test VM: extension enables cleanly, dashboard
shows the placeholder, switching to/from dashboard works, ding's window
is still ignored. MESA/EGL stderr lines are VM-only software-rendering
fallback noise (no virgl).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-23 00:22:51 +01:00
parent 5b871b8ecb
commit e1ae88948e
5 changed files with 255 additions and 18 deletions

View File

@@ -1,9 +1,144 @@
// Stub: see design §7 — web container process.
// Spawn container via Gio.Subprocess; match its window by Wayland app_id;
// pin to dashboard workspace; restart with exponential backoff on crash.
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import type Meta from 'gi://Meta';
import { log, warn, error } from './util/logger.js';
import type { WorkspaceWarden } from './workspace-warden.js';
import type { WindowGuard } from './window-guard.js';
// Design §7: web container process.
//
// Spawn the GJS container as a separate subprocess so its crash domain
// is its own; it identifies itself to mutter by setting prgname to a
// known app_id. We watch display::window-created, match wm_class to
// that app_id, whitelist the window with WindowGuard so it doesn't
// immediately get bounced, then pin it to the dashboard workspace and
// make it fullscreen.
//
// P3 keeps this minimal: spawn once, no automatic restart on crash, no
// graceful DBus shutdown. Restart / IPC come in P4+.
const APP_ID = 'top.hangmanlab.dashward.container';
export class ContainerSupervisor {
private readonly extensionPath: string;
private readonly warden: WorkspaceWarden;
private readonly guard: WindowGuard;
private process: Gio.Subprocess | null = null;
private window: Meta.Window | null = null;
private disposed = false;
constructor(extensionPath: string, warden: WorkspaceWarden, guard: WindowGuard) {
this.extensionPath = extensionPath;
this.warden = warden;
this.guard = guard;
global.display.connectObject(
'window-created',
(_d: Meta.Display, win: Meta.Window) => this.onWindowCreated(win),
this,
);
this.spawn();
}
dispose(): void {
// graceful shutdown via DBus, then SIGTERM after 2s
this.disposed = true;
global.display.disconnectObject(this);
if (this.window) {
this.guard.disallow(this.window);
this.window = null;
}
if (this.process) {
try {
this.process.send_signal(15); // SIGTERM
log('container SIGTERM sent');
} catch (e) {
warn(`container SIGTERM failed: ${String(e)}`);
}
this.process = null;
}
}
// -----------------------------------------------------------------------
private spawn(): void {
const containerScript = GLib.build_filenamev([this.extensionPath, 'container', 'main.js']);
const runtimeDir = GLib.build_filenamev([this.extensionPath, 'container']);
try {
this.process = Gio.Subprocess.new(
['gjs', '-m', containerScript, runtimeDir],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
);
const pid = this.process.get_identifier();
log(`container spawned pid=${pid} script=${containerScript}`);
// Drain stdout / stderr so the child doesn't block on a full pipe.
this.streamToJournal(this.process.get_stdout_pipe(), 'stdout');
this.streamToJournal(this.process.get_stderr_pipe(), 'stderr');
this.process.wait_async(null, (proc, res) => {
try {
(proc as Gio.Subprocess).wait_finish(res);
} catch (_e) {
// ignore — dispose may have killed it
}
if (!this.disposed) {
warn('container exited unexpectedly (no auto-restart in P3)');
}
});
} catch (e) {
error(`container spawn failed: ${String(e)}`);
}
}
private onWindowCreated(win: Meta.Window): void {
if (this.disposed) return;
if (this.window) return;
if (win.get_wm_class() !== APP_ID) return;
log(`container window arrived: "${win.get_title?.() ?? '?'}"`);
this.window = win;
this.guard.allow(win);
const dashboard = this.warden.getDashboardWorkspace();
if (dashboard && win.get_workspace()?.index() !== dashboard.index()) {
win.change_workspace(dashboard);
}
if (!win.is_fullscreen()) {
win.make_fullscreen();
}
win.connectObject(
'unmanaged',
() => {
if (this.window === win) this.window = null;
},
this,
);
}
private streamToJournal(stream: Gio.InputStream | null, tag: string): void {
if (!stream) return;
const dataStream = new Gio.DataInputStream({ base_stream: stream });
const read = () => {
dataStream.read_line_async(GLib.PRIORITY_DEFAULT, null, (src, res) => {
try {
const [line] = (src as Gio.DataInputStream).read_line_finish_utf8(res);
if (line === null) return; // EOF
log(`[container ${tag}] ${line}`);
read();
} catch (_e) {
// stream closed
}
});
};
read();
}
}

View File

@@ -34,8 +34,15 @@ export default class DashwardExtension extends Extension {
// can still tear down the warden cleanly.
}
// P3+ components still placeholders, wired in their own phases.
// this.container = new ContainerSupervisor(this.warden, this.guard);
if (this.guard) {
try {
this.container = new ContainerSupervisor(this.path, this.warden, this.guard);
} catch (e) {
error(`ContainerSupervisor init failed: ${String(e)}`);
}
}
// P4+ components still placeholders.
// this.entry = new EntryUX();
// this.dbus = new DBusService();
}