Files
system-taskbar/ding/app/htmlWidgetHost.js

509 lines
14 KiB
JavaScript

/* DING: Desktop Icons New Generation for GNOME Shell
*
* Gtk4 Port Copyright (C) 2022 - 2025 Sundeep Mediratta (smedius@gmail.com)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {GLib, GObject, Graphene, Gtk, Gsk} from '../dependencies/gi.js';
import {WidgetApi} from '../dependencies/localFiles.js';
export {HtmlWidgetHost};
const HtmlWidgetHost = class {
/**
* @param {object} params
* {
* instanceId: string,
* widgetId: string,
* frameRect: {x, y, width, height},
* widgetRegistry: WidgetRegistry | null,
* webContext: WebKit.WebContext,
* mode: 'prefs' or 'widget'
* prefsUri: string relative uri
* }
*/
constructor(params) {
this._instanceId = params.instanceId;
this._widgetId = params.widgetId;
this._frameRect = params.frameRect;
this._widgetRegistry = params.widgetRegistry;
this._webContext = params.webContext;
this._mode = params.mode === 'prefs' ? 'prefs' : 'widget';
this._prefsUri = params.prefsUri || null;
this._pendingHostStatePatches = [];
this._pendingPostMessages = [];
this._webView = null;
this._destroyed = false;
this._tickId = 0;
this._mappedNotifyId = 0;
this._makeGtkWidget();
this._start();
}
get actor() {
return this._frame;
}
_webViewReadyPromise() {
if (!this._webViewPromise) {
this._webViewPromise = new Promise(resolve => {
this._webViewResolve = resolve;
});
}
return this._webViewPromise;
}
getWebViewAsync() {
if (this._webView)
return this._webView;
return this._webViewReadyPromise();
}
updateFrame(frameRect) {
this._frameRect = frameRect;
this._frame.set_size_request(
frameRect.width,
frameRect.height
);
}
isAlive() {
return !this._destroyed;
}
destroy() {
this._destroyed = true;
if (this._mappedNotifyId && this._webView)
this._webView.disconnect(this._mappedNotifyId);
this._mappedNotifyId = 0;
if (this._tickId)
this._webView?.remove_tick_callback(this._tickId);
this._tickId = 0;
this._frame.set_child(null);
this._webView.unparent();
this._webView.run_dispose();
this._webView = null;
this._frame = null;
this._pendingHostStatePatches = [];
}
setHostStatePatch(patch) {
if (!patch || typeof patch !== 'object')
return;
if (this._destroyed || !this._webView) {
this._pendingHostStatePatches.push(patch);
return;
}
this._sendHostStatePatch(patch);
}
postMessage(msg) {
if (!msg || typeof msg !== 'object')
return;
if (this._destroyed || !this._webView) {
this._pendingPostMessages.push(msg);
return;
}
this._postMessage(msg);
}
async requestRender() {
if (this._destroyed)
return;
await this.getWebViewAsync();
this._pokeWebViewRender();
}
_makeGtkWidget() {
this._frame = new DingRoundedClip({radius: 8});
this._frame.set_size_request(
this._frameRect.width,
this._frameRect.height
);
this._frame.instanceId = this._instanceId;
this._frame.widgetId = this._widgetId;
}
async _makeWebView() {
this._webView =
await this._webContext.newViewForInstance(
this._widgetId,
this._instanceId
);
this._webView.set_overflow(Gtk.Overflow.HIDDEN);
this._webView.set_name('ding-widget-webview');
this._frame.set_child(this._webView);
}
async _makeUrl() {
let rel = null;
if (this._mode === 'prefs') {
rel = this._prefsUri || null;
} else {
const entryFile =
await this._widgetRegistry.getHtmlEntryFile(this._widgetId);
rel = entryFile ? entryFile.get_basename() : null;
}
if (!rel)
return null;
const id = GLib.uri_escape_string(this._instanceId, null, true);
const path = `/${GLib.uri_escape_string(rel, '/', true)}`;
const mode = this._mode === 'prefs' ? 'prefs' : 'widget';
const query =
`dingMode=${mode}&dingInstanceId=${id}`;
const guri = GLib.uri_build(
GLib.UriFlags.NONE,
'ding-widget',
null,
id,
-1,
path,
query,
null
);
return guri.to_string();
}
// ─────────────────────────
// start orchestration
// ─────────────────────────
async _start() {
const [_, url] = await Promise.all([
this._makeWebView(),
this._makeUrl(),
]).catch(e => logError(e));
this._webViewResolve?.(this._webView);
this._webViewPromise = null;
this._webViewResolve = null;
if (!this._webView)
return;
if (url)
this._webView.load_uri(url);
else
this._loadFallback('Missing entry/prefs URL');
this._installWebViewRenderPoke();
this._flushPendingHostStatePatches();
this._flushPendingMessages();
}
_flushPendingHostStatePatches() {
for (const patch of this._pendingHostStatePatches)
this._sendHostStatePatch(patch);
this._pendingHostStatePatches.length = 0;
}
_flushPendingMessages() {
for (const msg of this._pendingPostMessages)
this._postMessage(msg);
this._pendingPostMessages.length = 0;
}
_sendHostStatePatch(patch) {
let script;
try {
script =
'if (window.ding && ' +
'typeof window.ding._setHostState === "function") ' +
`window.ding._setHostState(${
JSON.stringify(patch)
});`;
} catch (e) {
console.error('HtmlWidgetHost: failed to build host state script:', e);
return;
}
this._evaluateScript(script);
}
_pokeWebViewRender() {
if (!this._webView || this._destroyed)
return;
const wv = this._webView;
if (this._tickId) {
wv.remove_tick_callback(this._tickId);
this._tickId = 0;
}
this._tickId = wv.add_tick_callback(() => {
const w = wv.get_allocated_width();
const h = wv.get_allocated_height();
if (w <= 1 || h <= 1)
return GObject.SOURCE_CONTINUE;
this._tickId = 0;
// Host-side “poke”: invalidate + optional JS nudge
wv.queue_draw();
wv.queue_allocate();
this._frame?.queue_draw();
this._frame?.queue_allocate();
this._nudgeWebViewDomRender();
return GObject.SOURCE_REMOVE;
});
}
_installWebViewRenderPoke() {
const wv = this._webView;
this._mappedNotifyId = wv.connect('notify::mapped', () => {
if (wv.get_mapped())
this._pokeWebViewRender();
});
if (wv.get_mapped())
this._pokeWebViewRender();
}
_nudgeWebViewDomRender() {
if (!this._webView || this._destroyed)
return;
this._evaluateScript(
`try {
const t = String(Date.now());
const de = document.documentElement;
const body = document.body;
if (de) {
de.style.setProperty('--ding-render-poke', t);
de.setAttribute('data-ding-render-poke', t);
}
if (body) {
body.style.setProperty('--ding-render-poke', t);
body.setAttribute('data-ding-render-poke', t);
}
window.dispatchEvent(new Event('resize'));
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('pageshow'));
window.dispatchEvent(new Event('focus'));
requestAnimationFrame(() => {});
} catch (e) {}`
);
}
_loadFallback(reason) {
if (!this._webView)
return;
const safeReason = GLib.markup_escape_text(String(reason), -1);
const html = WidgetApi.WIDGET_UNAVAILABLE_HTML.replace(
'__REASON__',
safeReason
);
this._webView.load_html(html, null);
}
_postMessage(msg) {
let script;
try {
script =
'if (typeof window.postMessage === "function") ' +
`window.postMessage(${JSON.stringify(msg)}, "*")`;
} catch (e) {
console.error(
'HtmlWidgetHost: failed to build postMessage script:', e
);
return;
}
this._evaluateScript(script);
}
_evaluateScript(script) {
if (this._destroyed || !this._webView)
return;
try {
this._webView?.evaluate_javascript(
script,
-1,
null,
null,
null,
(wv, res) => {
try {
if (!this._webView)
return;
wv?.evaluate_javascript_finish(res);
} catch (e) {
console.error(
'HtmlWidgetHost: failed to postMessage JS:', e
);
}
}
);
} catch (e) {
console.error(
'HtmlWidgetHost: failed to postMessage to widget:', e
);
}
}
};
/**
* DingRoundedClip
*
* A tiny single-child container that clips its child to a rounded rect
* using GTK4 snapshot APIs (push_rounded_clip).
*
* Intended to be used as the "frame" root for WebKitWebView so that GTK CSS
* border-radius matches the child's visible corners without visual
* artifacts as CSS is not clipping the webview's contents with a box or frame.
*/
export const DingRoundedClip = GObject.registerClass({
GTypeName: 'DingRoundedClip',
Properties: {
radius: GObject.ParamSpec.double(
'radius',
'Radius',
'Corner radius in pixels',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY,
0.0,
4096.0,
12.0
),
},
}, class DingRoundedClip extends Gtk.Widget {
_init(params = {}) {
super._init(params);
this._child = null;
this._radius = 12.0;
}
// ─────────────────────────
// properties
// ─────────────────────────
get radius() {
return this._radius;
}
set radius(v) {
const r = Math.max(0.0, Number(v) || 0.0);
if (r === this._radius)
return;
this._radius = r;
this.notify('radius');
this.queue_draw();
}
// ─────────────────────────
// child management
// ─────────────────────────
set_child(child) {
if (child === this._child)
return;
if (this._child) {
this._child.unparent();
this._child = null;
}
this._child = child ?? null;
if (this._child)
this._child.set_parent(this);
this.queue_resize();
}
get_child() {
return this._child;
}
// ─────────────────────────
// layout
// ─────────────────────────
vfunc_measure(orientation, forSize) {
if (!this._child)
return [0, 0, -1, -1];
return this._child.measure(orientation, forSize);
}
vfunc_size_allocate(width, height, baseline) {
if (!this._child)
return;
this._child.allocate(width, height, baseline, null);
}
// ─────────────────────────
// rendering
//
// Snapshot is called by GTK4 to render the widget inside the clipped area.
// ─────────────────────────
vfunc_snapshot(snapshot) {
if (!this._child)
return;
const width = this.get_width();
const height = this.get_height();
if (width <= 0 || height <= 0)
return;
const rect = new Graphene.Rect();
rect.init(0, 0, width, height);
const r = this._radius;
const size = new Graphene.Size();
size.init(r, r);
const rr = new Gsk.RoundedRect();
rr.init(rect, size, size, size, size);
snapshot.push_rounded_clip(rr);
this.snapshot_child(this._child, snapshot);
snapshot.pop();
}
});