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:
@@ -18,7 +18,10 @@ const runtimeOpts = {
|
|||||||
entryPoints: ['runtime/runtime.ts'],
|
entryPoints: ['runtime/runtime.ts'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
outfile: 'dist/runtime.js',
|
outfile: 'dist/runtime.js',
|
||||||
format: 'esm',
|
// IIFE so it loads as a classic <script>, avoiding WebKitGTK's
|
||||||
|
// file:// module loader quirks (the original `type="module"` script
|
||||||
|
// simply didn't execute).
|
||||||
|
format: 'iife',
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
platform: 'browser',
|
platform: 'browser',
|
||||||
sourcemap: 'inline',
|
sourcemap: 'inline',
|
||||||
|
|||||||
@@ -6,7 +6,28 @@
|
|||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Visible without JS. runtime.js removes it on mount; the error
|
||||||
|
handler below replaces its content if JS throws. -->
|
||||||
|
<div id="boot">dashboard.html loaded — waiting for runtime.js</div>
|
||||||
<main id="grid"></main>
|
<main id="grid"></main>
|
||||||
<script type="module" src="runtime.js"></script>
|
|
||||||
|
<script>
|
||||||
|
// Tiny inline diagnostic so a runtime.js syntax/runtime error is
|
||||||
|
// visible on screen (and forwarded to the host) instead of failing
|
||||||
|
// silently.
|
||||||
|
window.addEventListener('error', function (e) {
|
||||||
|
var b = document.getElementById('boot');
|
||||||
|
if (b) b.textContent = 'JS ERROR: ' + (e.message || '?') +
|
||||||
|
(e.filename ? ' @ ' + e.filename + ':' + e.lineno : '');
|
||||||
|
});
|
||||||
|
window.addEventListener('unhandledrejection', function (e) {
|
||||||
|
var b = document.getElementById('boot');
|
||||||
|
if (b) b.textContent = 'PROMISE REJECT: ' + String(e.reason);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- bundle is self-contained; classic script avoids file:// module
|
||||||
|
loader quirks in WebKitGTK. -->
|
||||||
|
<script src="runtime.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,28 +1,94 @@
|
|||||||
// Page-side runtime (design §11). For P3 the grid is just decoration —
|
// Page-side runtime (design §11). P4 establishes the shell-bridge and
|
||||||
// it renders a "no widgets yet" placeholder so the dashboard workspace
|
// uses it to follow the system color scheme. Real widget loading / edit
|
||||||
// has visible content. Real widget loading / edit mode / layout
|
// mode / layout persistence arrive in P5+.
|
||||||
// persistence come in P5+.
|
|
||||||
|
interface PendingCall {
|
||||||
|
resolve(value: unknown): void;
|
||||||
|
reject(err: Error): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashShellInternal {
|
||||||
|
call<T = unknown>(method: string, args?: unknown): Promise<T>;
|
||||||
|
_resolve(id: number, value: unknown): void;
|
||||||
|
_reject(id: number, error: string): void;
|
||||||
|
_onSignal(name: string, payload: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__dashShell__?: {
|
__dashShell__?: DashShellInternal;
|
||||||
call(method: string, args?: unknown): Promise<unknown>;
|
webkit?: { messageHandlers: { shellCall: { postMessage(data: string): void } } };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pending = new Map<number, PendingCall>();
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const dashShell: DashShellInternal = {
|
||||||
|
call<T>(method: string, args?: unknown): Promise<T> {
|
||||||
|
return new Promise<T>((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<string>('getTheme')
|
||||||
|
.then(applyTheme)
|
||||||
|
.catch(e => console.warn('[dashward] getTheme failed:', e));
|
||||||
|
|
||||||
|
window.addEventListener('dashward:ThemeChanged', evt => {
|
||||||
|
applyTheme((evt as CustomEvent<unknown>).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');
|
const grid = document.getElementById('grid');
|
||||||
if (grid) {
|
if (grid) {
|
||||||
const placeholder = document.createElement('section');
|
const placeholder = document.createElement('section');
|
||||||
placeholder.className = 'placeholder';
|
placeholder.className = 'placeholder';
|
||||||
placeholder.innerHTML = `
|
placeholder.innerHTML = `
|
||||||
<h1>Dashward</h1>
|
<h1>Dashward</h1>
|
||||||
<p class="hint">P3 — empty dashboard.</p>
|
<p class="hint">P4 — shell bridge online.</p>
|
||||||
<p class="meta">No widgets installed yet. Widget SDK + edit mode arrive in P5.</p>
|
<p class="meta">Theme follows system. Widgets land in P5.</p>
|
||||||
`;
|
`;
|
||||||
grid.appendChild(placeholder);
|
grid.appendChild(placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info('[dashward-runtime] P3 placeholder mounted');
|
console.info('[dashward-runtime] P4 bridge mounted');
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ body {
|
|||||||
box-sizing: border-box;
|
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 {
|
.placeholder {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,4 +1,82 @@
|
|||||||
// Stub: WebKit UserContentManager wiring (design §8.2).
|
import type WebKit2 from 'gi://WebKit2?version=4.1';
|
||||||
// Registers script-message handlers; evaluates JS into the page for shell→page push.
|
|
||||||
|
|
||||||
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<void> {
|
||||||
|
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<unknown> {
|
||||||
|
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, () => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<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 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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
// Dashward web container — GJS bootstrap.
|
// Dashward web container — GJS bootstrap.
|
||||||
// Spawned by the extension's ContainerSupervisor as a separate process so
|
// Spawned by the extension's ContainerSupervisor as a separate process so
|
||||||
// a crash here cannot bring down gnome-shell.
|
// 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 GLib from 'gi://GLib';
|
||||||
import Gtk from 'gi://Gtk?version=3.0';
|
import Gtk from 'gi://Gtk?version=3.0';
|
||||||
import WebKit2 from 'gi://WebKit2?version=4.1';
|
import WebKit2 from 'gi://WebKit2?version=4.1';
|
||||||
import System from 'system';
|
import System from 'system';
|
||||||
|
|
||||||
// Wayland's xdg-shell app_id is derived from prgname for plain GTK
|
import { ShellClient } from './dbus-client.js';
|
||||||
// windows (i.e. not a GApplication). Set this BEFORE any GTK init so
|
import { Bridge } from './bridge.js';
|
||||||
// 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';
|
const APP_ID = 'top.hangmanlab.dashward.container';
|
||||||
GLib.set_prgname(APP_ID);
|
GLib.set_prgname(APP_ID);
|
||||||
GLib.set_application_name('Dashward');
|
GLib.set_application_name('Dashward');
|
||||||
@@ -33,18 +28,36 @@ const window = new Gtk.Window({
|
|||||||
title: 'Dashward',
|
title: 'Dashward',
|
||||||
decorated: false,
|
decorated: false,
|
||||||
});
|
});
|
||||||
// X11 fallback for app_id; harmless on Wayland.
|
|
||||||
window.set_wmclass(APP_ID, APP_ID);
|
window.set_wmclass(APP_ID, APP_ID);
|
||||||
window.set_default_size(800, 600);
|
window.set_default_size(800, 600);
|
||||||
|
|
||||||
const webview = new WebKit2.WebView();
|
const webview = new WebKit2.WebView();
|
||||||
window.add(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`;
|
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);
|
webview.load_uri(indexUri);
|
||||||
print(`dashward-container: loading ${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.show_all();
|
||||||
window.fullscreen();
|
window.fullscreen();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,115 @@
|
|||||||
// Stub: see design §8.1 — Shell ↔ Container DBus.
|
import Gio from 'gi://Gio';
|
||||||
// Bus: top.hangmanlab.Dashward.Shell, path /top/hangmanlab/Dashward.
|
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 {
|
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 {
|
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 {
|
export default class DashwardExtension extends Extension {
|
||||||
private warden?: WorkspaceWarden;
|
private warden?: WorkspaceWarden;
|
||||||
private guard?: WindowGuard;
|
private guard?: WindowGuard;
|
||||||
|
private dbus?: DBusService;
|
||||||
private container?: ContainerSupervisor;
|
private container?: ContainerSupervisor;
|
||||||
private entry?: EntryUX;
|
private entry?: EntryUX;
|
||||||
private dbus?: DBusService;
|
private disposing = false;
|
||||||
|
|
||||||
override enable(): void {
|
override enable(): void {
|
||||||
log(`enable: ${this.metadata.uuid} v${this.metadata.version}`);
|
log(`enable: ${this.metadata.uuid} v${this.metadata.version}`);
|
||||||
@@ -21,8 +22,6 @@ export default class DashwardExtension extends Extension {
|
|||||||
this.warden = new WorkspaceWarden();
|
this.warden = new WorkspaceWarden();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(`WorkspaceWarden init failed: ${String(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,36 +29,52 @@ export default class DashwardExtension extends Extension {
|
|||||||
this.guard = new WindowGuard(this.warden);
|
this.guard = new WindowGuard(this.warden);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(`WindowGuard init failed: ${String(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) {
|
// Sequencing: own the DBus bus name BEFORE spawning the container, so
|
||||||
try {
|
// the container's proxy lookup hits a registered service. The
|
||||||
this.container = new ContainerSupervisor(this.path, this.warden, this.guard);
|
// container is spawned inside the DBus on-ready callback.
|
||||||
} catch (e) {
|
try {
|
||||||
error(`ContainerSupervisor init failed: ${String(e)}`);
|
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.entry = new EntryUX();
|
||||||
// this.dbus = new DBusService();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override disable(): void {
|
override disable(): void {
|
||||||
log('disable');
|
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.entry?.dispose();
|
||||||
this.container?.dispose();
|
this.container?.dispose();
|
||||||
|
this.dbus?.dispose();
|
||||||
this.guard?.dispose();
|
this.guard?.dispose();
|
||||||
this.warden?.dispose();
|
this.warden?.dispose();
|
||||||
|
|
||||||
this.dbus = undefined;
|
|
||||||
this.entry = undefined;
|
this.entry = undefined;
|
||||||
this.container = undefined;
|
this.container = undefined;
|
||||||
|
this.dbus = undefined;
|
||||||
this.guard = undefined;
|
this.guard = undefined;
|
||||||
this.warden = 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