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

645 lines
18 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/>.
*/
// widget-js-api.js
// Exports the script that gets injected into each WebView.
const transparencyCSS = `
/* Keep the page transparent without nuking widget element backgrounds */
html, body {
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
}
`;
export const CSP_STRICT = `
default-src 'none';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';
form-action 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
media-src 'self' blob:;
connect-src 'self' https: ;
navigate-to 'self';
block-all-mixed-content;
worker-src 'none';
frame-src 'none';
`;
export const CSP_DEV = `
default-src 'none';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';
form-action 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
media-src 'self' blob:;
connect-src
'self'
https:
http:
http://localhost:*
ws://localhost:*;
worker-src 'none';
frame-src 'none';
`;
export const CSP_RELAXED = `
default-src 'none';
base-uri 'self';
object-src 'none';
frame-ancestors 'none';
script-src
'self'
'unsafe-inline'
https:;
style-src
'self'
'unsafe-inline'
https:;
img-src 'self' data: blob: https:;
font-src 'self' data: https:;
media-src 'self' blob: https:;
connect-src
'self'
https:
http:
ws:
wss:;
worker-src blob:;
frame-src https:;
`;
export const WIDGET_UNAVAILABLE_HTML = `
<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;display:flex;align-items:center;justify-content:center;font:14px system-ui;background:rgba(0,0,0,0.05);color:#555;">
<div>
<div style="font-weight:600;">Widget unavailable</div>
<div style="font-size:12px;opacity:0.8;">__REASON__</div>
</div>
</body>
</html>`;
export const WIDGET_API =
`(function() {
'use strict';
// Avoid re-injecting if the page has already been initialized
if (window.ding)
return;
try {
const style = document.createElement('style');
style.id = 'ding-widget-background';
style.textContent = \`${transparencyCSS}\`;
document.documentElement.insertAdjacentElement('afterbegin', style);
} catch (e) {
// Failing to inject style is non-fatal.
console.error('ding: failed to inject default style', e);
}
// ---------------------------------------------------------------------
// Upward channel: widget -> host (via WebKit messageHandler)
// ---------------------------------------------------------------------
function post(message) {
try {
if (!window.webkit ||
!window.webkit.messageHandlers ||
!window.webkit.messageHandlers.dingWidget)
return;
window.webkit.messageHandlers.dingWidget.postMessage(
JSON.stringify(message)
);
} catch (e) {
try {
console.error('ding: post failed', e);
} catch (_ignored) {
// ignore logging failures
}
}
}
// ---------------------------------------------------------------------
// Small helper: query parameter parsing
// ---------------------------------------------------------------------
function getQueryParam(qs, key) {
if (!qs || qs.length <= 1)
return null;
if (qs.charAt(0) === '?')
qs = qs.substring(1);
var parts = qs.split('&');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].split('=');
if (kv.length === 2 && kv[0] === key) {
try {
return decodeURIComponent(kv[1]);
} catch (e) {
return kv[1];
}
}
}
return null;
}
// ---------------------------------------------------------------------
// Config cache + configChanged (downward push)
// ---------------------------------------------------------------------
var _configCache = {};
var _configListeners = new Set();
function _cloneObject(obj) {
if (!obj || typeof obj !== 'object')
return {};
var out = {};
for (var k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k))
out[k] = obj[k];
}
return out;
}
function _notifyConfigListeners(meta) {
var snapshot = _cloneObject(_configCache);
_configListeners.forEach(function(cb) {
try {
cb(snapshot, meta || null);
} catch (e) {
try {
console.error('ding: configChanged listener failed', e);
} catch (_ignored) {}
}
});
}
function _setConfigCache(nextConfig, meta) {
if (!nextConfig || typeof nextConfig !== 'object')
nextConfig = {};
_configCache = _cloneObject(nextConfig);
_notifyConfigListeners(meta);
}
function getConfigCached() {
return _cloneObject(_configCache);
}
// ---------------------------------------------------------------------
// getConfig request/response plumbing
// ---------------------------------------------------------------------
var pending = new Map();
var msgCounter = 1;
var _backendPending = new Map();
var _backendListeners = new Set();
// Host responds to getConfig by running:
// window.postMessage({ _dingInternal: true, requestId, config }, '*');
window.addEventListener('message', function(event) {
var data = event.data;
if (!data || data._dingInternal !== true)
return;
var type = data.type || null;
// --- Backend first ---
if (type === 'backendEvent') {
_backendListeners.forEach(function(cb) {
try {
cb(data.name, data.payload);
} catch (_e) {}
});
return;
}
if (type === 'backendReply') {
var requestId = data.requestId;
if (requestId === undefined || requestId === null)
return;
var pendingReq = _backendPending.get(requestId);
if (!pendingReq)
return;
_backendPending.delete(requestId);
if (data.ok)
pendingReq.resolve(data.result);
else
pendingReq.reject(data.error || {
code: 'E_BACKEND',
message: 'Backend request failed',
});
return;
}
// --- Config plumbing (only for config message types) ---
var requestId = data.requestId || null;
var config = data.config;
if (config && typeof config === 'object') {
_setConfigCache(config, {
reason: type === 'configChanged'
? (data.reason || 'configChanged')
: 'getConfigReply',
sourceMode: data.sourceMode || null,
});
}
if (type === 'configChanged')
return;
if (!requestId)
return;
var resolver = pending.get(requestId);
if (!resolver)
return;
pending.delete(requestId);
try {
resolver(config || {});
} catch (e) {
try {
console.error('ding: getConfig resolver failed', e);
} catch (_ignored) {}
}
});
// ---------------------------------------------------------------------
// Host state (downward channel)
// ---------------------------------------------------------------------
var _hostState = {
editMode: false,
selected: false,
theme: 'light',
reducedMotion: false,
direction: 'ltr',
locale: (typeof navigator !== 'undefined' && navigator.language) ?
navigator.language :
'en_US',
};
var _hostStateListeners = new Set();
// ---------------------------------------------------------------------
// Page-side debug flag:
// Host can flip this with:
// webView.evaluate_javascript("window.DING_DEBUG_HOST_STATE = true;", ...)
// ---------------------------------------------------------------------
window.DING_DEBUG_HOST_STATE = false;
function _debugHostState(msg, data) {
if (!window.DING_DEBUG_HOST_STATE)
return;
try {
console.log('ding[host-state]', msg, data);
} catch (_e) {
// ignore
}
}
function _applyHostStateToDom() {
try {
var docEl = document.documentElement;
var body = document.body;
if (!docEl || !body)
return;
// Direction
docEl.dir = _hostState.direction || 'ltr';
// Theme
body.dataset.theme = _hostState.theme || 'light';
// Edit mode & selection
body.classList.toggle('ding-edit-mode', !!_hostState.editMode);
body.classList.toggle('ding-selected', !!_hostState.selected);
// Reduced motion:
body.classList.toggle('ding-reduced-motion', !!_hostState.reducedMotion);
} catch (_e) {
// Don't let DOM sync failures break host-state updates
}
}
function _cloneHostState() {
var out = {};
for (var k in _hostState) {
if (Object.prototype.hasOwnProperty.call(_hostState, k))
out[k] = _hostState[k];
}
return out;
}
function _notifyHostStateListeners() {
var snapshot = _cloneHostState();
_debugHostState('notify', snapshot);
_applyHostStateToDom()
_hostStateListeners.forEach(function(cb) {
try {
cb(snapshot);
} catch (e) {
try {
console.error('ding: hostState listener failed', e);
} catch (_ignored) {}
}
});
}
function _setHostState(patch) {
if (!patch || typeof patch !== 'object')
return;
_debugHostState('patch', patch);
var changed = false;
for (var key in patch) {
if (!Object.prototype.hasOwnProperty.call(patch, key))
continue;
var value = patch[key];
if (_hostState[key] !== value) {
_hostState[key] = value;
changed = true;
}
}
if (changed)
_notifyHostStateListeners();
}
// ---------------------------------------------------------------------
// Instance ID derivation from URL query parameters
// ---------------------------------------------------------------------
var initialInstanceId = null;
var initialMode = 'widget'; // default
try {
var search = (window.location && window.location.search) || '';
var qmode = getQueryParam(search, 'dingMode');
if (qmode === 'prefs' || qmode === 'widget')
initialMode = qmode;
var qid =
getQueryParam(search, 'dingInstanceId') ||
getQueryParam(search, 'widgetInstanceId') ||
getQueryParam(search, 'instanceId');
if (qid)
initialInstanceId = qid;
} catch (e) {
// ignore
}
// ---------------------------------------------------------------------
// Public API: window.ding
// ---------------------------------------------------------------------
window.ding = {
apiVersion: 1,
instanceId: initialInstanceId,
mode: initialMode,
// Expose raw post() if widgets want it
post: post,
getInstanceId: function() {
return this.instanceId;
},
// -----------------------------
// Widget -> host helpers
// -----------------------------
log: function(message) {
post({
type: 'log',
instanceId: this.instanceId,
message: String(message),
});
},
saveConfig: function(config) {
if (!this.instanceId)
return;
post({
type: 'updateConfig',
instanceId: this.instanceId,
config: config || {},
});
},
getConfig: function() {
if (!this.instanceId)
return Promise.resolve(null);
var requestId = msgCounter++;
return new Promise(function(resolve) {
pending.set(requestId, resolve);
post({
type: 'getConfig',
instanceId: window.ding.instanceId,
requestId: requestId,
});
});
},
// Can return {} if the cache is not initialized yet
getConfigSync: function() {
return getConfigCached();
},
// -----------------------------
// Host -> widget helpers
// -----------------------------
/**
* Returns a shallow copy of the current host state:
* {
* editMode, selected, theme, visible,
* reducedMotion, direction, locale
* }
*/
getHostState: function() {
return _cloneHostState();
},
/**
* Subscribe to host state changes.
* Returns an unsubscribe() function.
*/
onHostStateChanged: function(cb) {
if (typeof cb !== 'function')
return function() {};
_hostStateListeners.add(cb);
// Immediately deliver current snapshot
try {
cb(_cloneHostState());
} catch (e) {
try {
console.error('ding: hostState listener failed (initial)', e);
} catch (_ignored) {}
}
return function() {
_hostStateListeners.delete(cb);
};
},
onConfigChanged: function(cb) {
if (typeof cb !== 'function')
return function() {};
_configListeners.add(cb);
try {
cb(
_cloneObject(_configCache),
{ reason: 'initial', sourceMode: null }
);
} catch (_e) {}
// Return unsubscribe
return function() {
_configListeners.delete(cb);
};
},
backendRequest: function(method, params) {
if (!this.instanceId)
return Promise.reject(new Error('No instanceId'));
var requestId = msgCounter++;
return new Promise(function(resolve, reject) {
_backendPending.set(requestId, { resolve, reject });
post({
type: 'backendRequest',
instanceId: window.ding.instanceId,
requestId: requestId,
method: method,
params: params || {},
});
});
},
backendSend: function(name, payload) {
if (!this.instanceId)
return;
post({
type: 'backendSend',
instanceId: window.ding.instanceId,
name: name,
payload: payload || {},
});
},
onBackendEvent: function(cb) {
if (typeof cb !== 'function')
return function() {};
_backendListeners.add(cb);
return function() {
_backendListeners.delete(cb);
};
},
// ---------------------------------------------------------------------
// INTERNAL: host-only entrypoint. The GJS side calls this via
// evaluate_javascript() to push patches into _hostState.
// ---------------------------------------------------------------------
_setHostState: _setHostState,
};
(function() {
function doInitialDomSync() {
try {
_debugHostState('initial-dom-sync', _cloneHostState());
_notifyHostStateListeners();
} catch (_e) {
// Ignore; we don't want this to break the widget
}
}
if (document.readyState === 'loading') {
// Body not ready yet; wait for DOMContentLoaded
document.addEventListener('DOMContentLoaded', function onReady() {
document.removeEventListener('DOMContentLoaded', onReady);
doInitialDomSync();
});
} else {
// DOM is already ready (e.g. script injected later)
doInitialDomSync();
}
})();
// Notify the host that the widget API is ready and has an instanceId
try {
post({
type: 'hostReady',
instanceId: initialInstanceId || null,
});
} catch (_e) {
// ignore
}
})();`;