From 5ed4170e696e6d5ca84a697bddc18a5f0eb24460 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 00:54:19 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20P4=20=E2=80=94=20DBus=20bridge=20+=20pa?= =?UTF-8?q?ge=20round-trip=20+=20system=20theme=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new wires from page to shell and back, proving the bridge end to end. The dashboard placeholder now follows the system color-scheme via the full chain: page-side dashShell.call('getTheme') → WebKit script message handler → DBus method on the shell-owned service → gsettings read → return value back up. ThemeChanged proves the reverse direction: the shell watches color-scheme and emits a DBus signal that the container forwards to the page as a CustomEvent. extension/src/dbus-service.ts Owns top.hangmanlab.Dashward.Shell on the session bus; exports GetTheme (reads org.gnome.desktop.interface::color-scheme, maps prefer-dark→dark / else→light); emits ThemeChanged on the gsettings changed signal. Bus acquisition is async, so the constructor takes an onReady callback that fires from the bus_acquired handler. extension/src/extension.ts Sequencing: warden → guard → dbus, and only after dbus_acquired callback fires do we spawn ContainerSupervisor. Disable order is reversed (container first so its DBus proxy stops calling the service before we unown the bus name). container/src/dbus-client.ts Thin GJS proxy wrapper around the Shell interface; exposes a typed getTheme() / onThemeChange(cb) API. container/src/bridge.ts Registers a `shellCall` UCM script-message handler; parses {id, method, args} JSON from the page, dispatches to invoke(), and feeds the result back via evaluate_javascript. Shell signals are forwarded to the page via window.__dashShell__._onSignal. container/runtime/runtime.ts Installs window.__dashShell__ at script start, calls getTheme() on load and applies the result to , listens for the ThemeChanged CustomEvent. container/runtime/dashboard.html + esbuild.config.js Switched the runtime bundle from `format: 'esm'` to IIFE so it loads as a classic + + + + + diff --git a/container/runtime/runtime.ts b/container/runtime/runtime.ts index 403304f..7c98927 100644 --- a/container/runtime/runtime.ts +++ b/container/runtime/runtime.ts @@ -1,28 +1,94 @@ -// 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+. +// Page-side runtime (design §11). P4 establishes the shell-bridge and +// uses it to follow the system color scheme. Real widget loading / edit +// mode / layout persistence arrive in P5+. + +interface PendingCall { + resolve(value: unknown): void; + reject(err: Error): void; +} + +interface DashShellInternal { + call(method: string, args?: unknown): Promise; + _resolve(id: number, value: unknown): void; + _reject(id: number, error: string): void; + _onSignal(name: string, payload: unknown): void; +} declare global { interface Window { - __dashShell__?: { - call(method: string, args?: unknown): Promise; - }; + __dashShell__?: DashShellInternal; + webkit?: { messageHandlers: { shellCall: { postMessage(data: string): void } } }; } } +const pending = new Map(); +let nextId = 1; + +const dashShell: DashShellInternal = { + call(method: string, args?: unknown): Promise { + return new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve: resolve as (v: unknown) => void, reject }); + try { + window.webkit!.messageHandlers.shellCall.postMessage( + JSON.stringify({ id, method, args }), + ); + } catch (e) { + pending.delete(id); + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + }, + _resolve(id, value) { + const p = pending.get(id); + if (!p) return; + pending.delete(id); + p.resolve(value); + }, + _reject(id, err) { + const p = pending.get(id); + if (!p) return; + pending.delete(id); + p.reject(new Error(err)); + }, + _onSignal(name, payload) { + window.dispatchEvent(new CustomEvent(`dashward:${name}`, { detail: payload })); + }, +}; + +window.__dashShell__ = dashShell; + +// Theme: ask once on load, then follow ThemeChanged from the shell. +function applyTheme(theme: unknown): void { + if (typeof theme !== 'string') return; + document.documentElement.dataset.theme = theme; +} + +dashShell + .call('getTheme') + .then(applyTheme) + .catch(e => console.warn('[dashward] getTheme failed:', e)); + +window.addEventListener('dashward:ThemeChanged', evt => { + applyTheme((evt as CustomEvent).detail); +}); + +// Remove the boot diagnostic now that runtime.js is executing. +document.getElementById('boot')?.remove(); + +// Placeholder card so the dashboard workspace is visually distinct. 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.

+

P4 — shell bridge online.

+

Theme follows system. Widgets land in P5.

`; grid.appendChild(placeholder); } -console.info('[dashward-runtime] P3 placeholder mounted'); +console.info('[dashward-runtime] P4 bridge mounted'); export {}; diff --git a/container/runtime/styles.css b/container/runtime/styles.css index eb1e430..0b90b56 100644 --- a/container/runtime/styles.css +++ b/container/runtime/styles.css @@ -33,6 +33,19 @@ body { box-sizing: border-box; } +#boot { + position: fixed; + top: 16px; + left: 16px; + padding: 8px 12px; + background: rgba(255, 80, 80, 0.85); + color: white; + font-family: ui-monospace, monospace; + font-size: 12px; + border-radius: 6px; + z-index: 9999; +} + .placeholder { grid-column: 1 / -1; display: grid; diff --git a/container/src/bridge.ts b/container/src/bridge.ts index fcaac35..f882db2 100644 --- a/container/src/bridge.ts +++ b/container/src/bridge.ts @@ -1,4 +1,82 @@ -// Stub: WebKit UserContentManager wiring (design §8.2). -// Registers script-message handlers; evaluates JS into the page for shell→page push. +import type WebKit2 from 'gi://WebKit2?version=4.1'; -export {}; +import type { ShellClient } from './dbus-client.js'; + +// Design §8.2 — UCM script-message handlers carry page → shell calls. +// Reverse direction is evaluate_javascript(...). + +interface PageCall { + id: number; + method: string; + args?: unknown; +} + +export class Bridge { + private readonly webview: WebKit2.WebView; + private readonly shell: ShellClient; + private themeUnsub: (() => void) | null = null; + + constructor(webview: WebKit2.WebView, shell: ShellClient) { + this.webview = webview; + this.shell = shell; + + const ucm = webview.get_user_content_manager(); + ucm.connect('script-message-received::shellCall', (_ucm: WebKit2.UserContentManager, msg: WebKit2.JavascriptResult) => { + this.handleCall(msg); + }); + ucm.register_script_message_handler('shellCall'); + + this.themeUnsub = shell.onThemeChange(theme => { + this.pushSignal('ThemeChanged', theme); + }); + } + + dispose(): void { + this.themeUnsub?.(); + this.themeUnsub = null; + } + + // ----------------------------------------------------------------------- + + private handleCall(msg: WebKit2.JavascriptResult): void { + let parsed: PageCall; + try { + const value = msg.get_js_value(); + const raw = value.to_string(); + parsed = JSON.parse(raw) as PageCall; + } catch (e) { + printerr(`bridge: bad message: ${String(e)}`); + return; + } + + void this.dispatch(parsed); + } + + private async dispatch(call: PageCall): Promise { + try { + const result = await this.invoke(call.method, call.args); + this.evalJs(`window.__dashShell__?._resolve(${call.id}, ${JSON.stringify(result)});`); + } catch (e) { + this.evalJs( + `window.__dashShell__?._reject(${call.id}, ${JSON.stringify(String(e))});`, + ); + } + } + + private async invoke(method: string, _args: unknown): Promise { + switch (method) { + case 'getTheme': + return await this.shell.getTheme(); + default: + throw new Error(`unknown shell method: ${method}`); + } + } + + private pushSignal(name: string, payload: unknown): void { + this.evalJs(`window.__dashShell__?._onSignal(${JSON.stringify(name)}, ${JSON.stringify(payload)});`); + } + + private evalJs(code: string): void { + this.webview.evaluate_javascript(code, -1, null, null, null, () => {}); + } +} diff --git a/container/src/dbus-client.ts b/container/src/dbus-client.ts index c52df18..4a1c0cc 100644 --- a/container/src/dbus-client.ts +++ b/container/src/dbus-client.ts @@ -1,3 +1,61 @@ -// Stub: DBus client for top.hangmanlab.Dashward.Shell (design §8.1). +import Gio from 'gi://Gio'; -export {}; +// Mirror of extension/src/dbus-service.ts's interface XML. Keep in sync. +const IFACE_XML = ` + + + + + + + + + + +`; + +const BUS_NAME = 'top.hangmanlab.Dashward.Shell'; +const OBJECT_PATH = '/top/hangmanlab/Dashward'; + +type ShellProxy = { + GetThemeAsync(): Promise<[string]>; + connectSignal(name: 'ThemeChanged', cb: (proxy: ShellProxy, sender: string, args: [string]) => void): number; + disconnectSignal(id: number): void; +}; + +const ShellProxyClass = (Gio.DBusProxy as unknown as { + makeProxyWrapper(xml: string): new (conn: Gio.DBusConnection, name: string, path: string) => ShellProxy; +}).makeProxyWrapper(IFACE_XML); + +export class ShellClient { + private proxy: ShellProxy; + private themeSignalId = 0; + private themeListeners = new Set<(theme: string) => void>(); + + constructor() { + this.proxy = new ShellProxyClass(Gio.DBus.session, BUS_NAME, OBJECT_PATH); + this.themeSignalId = this.proxy.connectSignal('ThemeChanged', (_p, _s, [theme]) => { + for (const cb of this.themeListeners) cb(theme); + }); + } + + async getTheme(): Promise { + const [theme] = await this.proxy.GetThemeAsync(); + return theme; + } + + onThemeChange(cb: (theme: string) => void): () => void { + this.themeListeners.add(cb); + return () => this.themeListeners.delete(cb); + } + + dispose(): void { + if (this.themeSignalId) { + try { + this.proxy.disconnectSignal(this.themeSignalId); + } catch (_e) { /* ignore */ } + this.themeSignalId = 0; + } + this.themeListeners.clear(); + } +} diff --git a/container/src/main.ts b/container/src/main.ts index 1a1b6a1..3e00d32 100644 --- a/container/src/main.ts +++ b/container/src/main.ts @@ -1,20 +1,15 @@ // 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'; -// 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. +import { ShellClient } from './dbus-client.js'; +import { Bridge } from './bridge.js'; + const APP_ID = 'top.hangmanlab.dashward.container'; GLib.set_prgname(APP_ID); GLib.set_application_name('Dashward'); @@ -33,18 +28,36 @@ const window = new Gtk.Window({ 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); +// Set up the shell bridge BEFORE loading the page, so the very first +// __dashShell__.call() from page-side runtime.js has a registered +// handler waiting. UCM handler registration is synchronous and stays +// alive for the WebView's lifetime. +const shell = new ShellClient(); +const bridge = new Bridge(webview, shell); + const indexUri = `file://${runtimeDir}/dashboard.html`; +webview.connect('load-changed', (_v: WebKit2.WebView, ev: WebKit2.LoadEvent) => { + const names = ['STARTED', 'REDIRECTED', 'COMMITTED', 'FINISHED']; + print(`dashward-container: load-changed=${names[ev] ?? ev}`); +}); +webview.connect('load-failed', (_v, _ev, failingUri, error) => { + printerr(`dashward-container: load-failed uri=${failingUri} err=${(error as { message?: string })?.message ?? '?'}`); + return false; +}); webview.load_uri(indexUri); print(`dashward-container: loading ${indexUri}`); -window.connect('destroy', () => Gtk.main_quit()); +window.connect('destroy', () => { + bridge.dispose(); + shell.dispose(); + Gtk.main_quit(); +}); window.show_all(); window.fullscreen(); diff --git a/extension/src/dbus-service.ts b/extension/src/dbus-service.ts index 86bdd35..dfcfd0b 100644 --- a/extension/src/dbus-service.ts +++ b/extension/src/dbus-service.ts @@ -1,8 +1,115 @@ -// Stub: see design §8.1 — Shell ↔ Container DBus. -// Bus: top.hangmanlab.Dashward.Shell, path /top/hangmanlab/Dashward. +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import { log, warn } from './util/logger.js'; + +// Design §8.1: Shell ↔ Container over DBus. +// +// P4 minimum: GetTheme (round-trip proof + real use case) and the +// ThemeChanged signal (proves the reverse direction). The rest of the +// interface (RequestEnterDashboard, GetSystemInfo for battery/network, +// WidgetIPC routing) lands in later phases as widgets need them. + +const BUS_NAME = 'top.hangmanlab.Dashward.Shell'; +const OBJECT_PATH = '/top/hangmanlab/Dashward'; + +const IFACE_XML = ` + + + + + + + + + + +`; + +const SCHEMA_INTERFACE = 'org.gnome.desktop.interface'; +const KEY_COLOR_SCHEME = 'color-scheme'; export class DBusService { + private readonly interfaceSettings: Gio.Settings; + private readonly onReady: () => void; + + private busOwnerId = 0; + private exportedObj: Gio.DBusExportedObject | null = null; + private settingsHandlerId = 0; + private ready = false; + + constructor(onReady: () => void) { + this.interfaceSettings = new Gio.Settings({ schema_id: SCHEMA_INTERFACE }); + this.onReady = onReady; + + this.busOwnerId = Gio.bus_own_name( + Gio.BusType.SESSION, + BUS_NAME, + Gio.BusNameOwnerFlags.NONE, + (conn: Gio.DBusConnection) => this.onBusAcquired(conn), + null, + (_conn, name) => warn(`lost bus name ${name}`), + ); + log(`requesting bus name ${BUS_NAME}`); + } + + getTheme(): 'light' | 'dark' { + const scheme = this.interfaceSettings.get_string(KEY_COLOR_SCHEME); + return scheme === 'prefer-dark' ? 'dark' : 'light'; + } + dispose(): void { - // unown bus name, disconnect handlers + if (this.settingsHandlerId) { + this.interfaceSettings.disconnect(this.settingsHandlerId); + this.settingsHandlerId = 0; + } + if (this.exportedObj) { + try { + this.exportedObj.unexport(); + } catch (e) { + warn(`unexport failed: ${String(e)}`); + } + this.exportedObj = null; + } + if (this.busOwnerId) { + Gio.bus_unown_name(this.busOwnerId); + this.busOwnerId = 0; + } + log('dbus disposed'); + } + + // ----------------------------------------------------------------------- + + private onBusAcquired(conn: Gio.DBusConnection): void { + const impl = { + GetTheme: () => this.getTheme(), + }; + this.exportedObj = Gio.DBusExportedObject.wrapJSObject(IFACE_XML, impl); + + try { + this.exportedObj.export(conn, OBJECT_PATH); + } catch (e) { + warn(`dbus export failed: ${String(e)}`); + return; + } + + this.settingsHandlerId = this.interfaceSettings.connect( + `changed::${KEY_COLOR_SCHEME}`, + () => this.emitThemeChanged(), + ); + + log(`dbus exported at ${BUS_NAME} ${OBJECT_PATH}`); + + if (!this.ready) { + this.ready = true; + this.onReady(); + } + } + + private emitThemeChanged(): void { + if (!this.exportedObj) return; + const theme = this.getTheme(); + this.exportedObj.emit_signal('ThemeChanged', new GLib.Variant('(s)', [theme])); + log(`emitted ThemeChanged(${theme})`); } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index cb70903..59c9733 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -10,9 +10,10 @@ import { log, error } from './util/logger.js'; export default class DashwardExtension extends Extension { private warden?: WorkspaceWarden; private guard?: WindowGuard; + private dbus?: DBusService; private container?: ContainerSupervisor; private entry?: EntryUX; - private dbus?: DBusService; + private disposing = false; override enable(): void { log(`enable: ${this.metadata.uuid} v${this.metadata.version}`); @@ -21,8 +22,6 @@ export default class DashwardExtension extends Extension { 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; } @@ -30,36 +29,52 @@ export default class DashwardExtension extends Extension { this.guard = new WindowGuard(this.warden); } catch (e) { error(`WindowGuard init failed: ${String(e)}`); - // Dashboard workspace exists but is undefended. Continue so disable() - // can still tear down the warden cleanly. } - if (this.guard) { - try { - this.container = new ContainerSupervisor(this.path, this.warden, this.guard); - } catch (e) { - error(`ContainerSupervisor init failed: ${String(e)}`); - } + // Sequencing: own the DBus bus name BEFORE spawning the container, so + // the container's proxy lookup hits a registered service. The + // container is spawned inside the DBus on-ready callback. + try { + this.dbus = new DBusService(() => this.spawnContainerOnce()); + } catch (e) { + error(`DBusService init failed: ${String(e)}`); } - // P4+ components still placeholders. + // P5+ components still placeholders. // this.entry = new EntryUX(); - // this.dbus = new DBusService(); } override disable(): void { log('disable'); + this.disposing = true; - this.dbus?.dispose(); + // Reverse of construction order. Container goes first so its proxy + // stops calling the service before we unown the bus name. this.entry?.dispose(); this.container?.dispose(); + this.dbus?.dispose(); this.guard?.dispose(); this.warden?.dispose(); - this.dbus = undefined; this.entry = undefined; this.container = undefined; + this.dbus = undefined; this.guard = undefined; this.warden = undefined; + this.disposing = false; + } + + private spawnContainerOnce(): void { + if (this.disposing) return; + if (this.container) return; + if (!this.warden || !this.guard) { + error('cannot spawn container: warden or guard missing'); + return; + } + try { + this.container = new ContainerSupervisor(this.path, this.warden, this.guard); + } catch (e) { + error(`ContainerSupervisor init failed: ${String(e)}`); + } } }