From e1ae88948e7ae51180df471e4973b4f83df6c1c6 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 00:22:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20P3=20=E2=80=94=20WebKit=20container=20p?= =?UTF-8?q?rocess=20pinned=20to=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- container/runtime/runtime.ts | 21 +++- container/runtime/styles.css | 45 +++++++- container/src/main.ts | 53 ++++++++-- extension/src/container-supervisor.ts | 143 +++++++++++++++++++++++++- extension/src/extension.ts | 11 +- 5 files changed, 255 insertions(+), 18 deletions(-) diff --git a/container/runtime/runtime.ts b/container/runtime/runtime.ts index 771b3d1..403304f 100644 --- a/container/runtime/runtime.ts +++ b/container/runtime/runtime.ts @@ -1,6 +1,7 @@ -// Stub: page-side runtime (design §11). -// Loads layout.json, instantiates widgets in iframes on the 12-column grid, -// handles edit mode, persists changes via the shell bridge. +// Page-side runtime (design §11). For P3 the grid is just decoration — +// it renders a "no widgets yet" placeholder so the dashboard workspace +// has visible content. Real widget loading / edit mode / layout +// persistence come in P5+. declare global { interface Window { @@ -10,6 +11,18 @@ declare global { } } -console.info('[dashward-runtime] P0 stub loaded'); +const grid = document.getElementById('grid'); +if (grid) { + const placeholder = document.createElement('section'); + placeholder.className = 'placeholder'; + placeholder.innerHTML = ` +

Dashward

+

P3 — empty dashboard.

+

No widgets installed yet. Widget SDK + edit mode arrive in P5.

+ `; + grid.appendChild(placeholder); +} + +console.info('[dashward-runtime] P3 placeholder mounted'); export {}; diff --git a/container/runtime/styles.css b/container/runtime/styles.css index 0cca405..eb1e430 100644 --- a/container/runtime/styles.css +++ b/container/runtime/styles.css @@ -1,16 +1,28 @@ :root { --bg: #f5f5f7; --fg: #1c1c1e; + --fg-muted: rgba(28, 28, 30, 0.55); --grid-gap: 16px; } [data-theme="dark"] { --bg: #0f0f12; --fg: #f0f0f5; + --fg-muted: rgba(240, 240, 245, 0.55); } -html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); } -body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; } +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--fg); +} + +body { + font-family: ui-sans-serif, system-ui, -apple-system, 'Inter', sans-serif; + -webkit-font-smoothing: antialiased; +} #grid { display: grid; @@ -20,3 +32,32 @@ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; } min-height: 100%; box-sizing: border-box; } + +.placeholder { + grid-column: 1 / -1; + display: grid; + place-items: center; + align-content: center; + gap: 12px; + min-height: 80vh; + text-align: center; +} + +.placeholder h1 { + font-size: clamp(3rem, 8vw, 6rem); + font-weight: 200; + letter-spacing: -0.02em; + margin: 0; +} + +.placeholder .hint { + font-size: 1.25rem; + margin: 0; + opacity: 0.7; +} + +.placeholder .meta { + font-size: 0.9rem; + margin: 0; + color: var(--fg-muted); +} diff --git a/container/src/main.ts b/container/src/main.ts index 84d091c..1a1b6a1 100644 --- a/container/src/main.ts +++ b/container/src/main.ts @@ -1,10 +1,51 @@ -// Stub: see design §7 — WebKit kiosk bootstrap. -// Creates a GtkWindow, attaches a WebKitWebView, loads dashboard.html, -// connects to the Shell DBus service. +// Dashward web container — GJS bootstrap. +// Spawned by the extension's ContainerSupervisor as a separate process so +// a crash here cannot bring down gnome-shell. +// +// argv[0] (after the script): the runtime directory containing +// dashboard.html, runtime.js, styles.css. The directory is passed by the +// extension so we don't have to guess our install path. import GLib from 'gi://GLib'; +import Gtk from 'gi://Gtk?version=3.0'; +import WebKit2 from 'gi://WebKit2?version=4.1'; +import System from 'system'; -GLib.set_prgname('dashward-container'); -log('[dashward-container] P0 stub started'); +// Wayland's xdg-shell app_id is derived from prgname for plain GTK +// windows (i.e. not a GApplication). Set this BEFORE any GTK init so +// the compositor labels our top-level correctly -- the extension uses +// this app_id to identify and pin our window to the dashboard workspace. +const APP_ID = 'top.hangmanlab.dashward.container'; +GLib.set_prgname(APP_ID); +GLib.set_application_name('Dashward'); -declare function log(msg: string): void; +const args = System.programArgs; +const runtimeDir = args[0]; +if (!runtimeDir) { + printerr('dashward-container: missing runtime dir argument'); + System.exit(2); +} + +Gtk.init(null); + +const window = new Gtk.Window({ + type: Gtk.WindowType.TOPLEVEL, + title: 'Dashward', + decorated: false, +}); +// X11 fallback for app_id; harmless on Wayland. +window.set_wmclass(APP_ID, APP_ID); +window.set_default_size(800, 600); + +const webview = new WebKit2.WebView(); +window.add(webview); + +const indexUri = `file://${runtimeDir}/dashboard.html`; +webview.load_uri(indexUri); +print(`dashward-container: loading ${indexUri}`); + +window.connect('destroy', () => Gtk.main_quit()); +window.show_all(); +window.fullscreen(); + +Gtk.main(); diff --git a/extension/src/container-supervisor.ts b/extension/src/container-supervisor.ts index 64968f2..3d5004f 100644 --- a/extension/src/container-supervisor.ts +++ b/extension/src/container-supervisor.ts @@ -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(); } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 44cba73..cb70903 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -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(); }