feat: P4 — DBus bridge + page round-trip + system theme tracking

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 <html data-theme>, 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 <script> -- WebKitGTK silently refuses `<script
  type="module">` over file:// for ES module resolution. Added an
  inline error catcher that surfaces JS errors in the visible boot
  diagnostic div instead of failing silently.

container/src/main.ts
  Construct ShellClient + Bridge before load_uri so the very first
  page-side dashShell.call() has a handler waiting. Added load-changed
  / load-failed signal logging for future diagnosis.

Verified on ubuntu2504-test VM: enable produces clean log chain through
"container window arrived", page renders with "P4 — shell bridge
online" placeholder and the correct system theme on first paint, manual
`gsettings set ... color-scheme prefer-dark` flips the placeholder
background live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-23 00:54:19 +01:00
parent e1ae88948e
commit 5ed4170e69
9 changed files with 419 additions and 45 deletions

View File

@@ -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 = `
<node>
<interface name="top.hangmanlab.Dashward.Shell">
<method name="GetTheme">
<arg type="s" direction="out" name="theme"/>
</method>
<signal name="ThemeChanged">
<arg type="s" name="theme"/>
</signal>
</interface>
</node>
`;
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})`);
}
}

View File

@@ -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)}`);
}
}
}