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:
@@ -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})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user