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:
@@ -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 = `
|
||||
<h1>Dashward</h1>
|
||||
<p class="hint">P3 — empty dashboard.</p>
|
||||
<p class="meta">No widgets installed yet. Widget SDK + edit mode arrive in P5.</p>
|
||||
`;
|
||||
grid.appendChild(placeholder);
|
||||
}
|
||||
|
||||
console.info('[dashward-runtime] P3 placeholder mounted');
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user