2261 lines
71 KiB
JavaScript
2261 lines
71 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 {Adw, Gio, GLib, Gtk} from '../dependencies/gi.js';
|
||
import {_} from '../dependencies/gettext.js';
|
||
import {WidgetRegistry} from '../dependencies/localFiles.js';
|
||
import {HtmlWidgetHost, HtmlWidgetHostWithBackend} from '../dependencies/localFiles.js';
|
||
import {WebWidgetContext} from '../dependencies/localFiles.js';
|
||
|
||
/**
|
||
* WidgetManager
|
||
*
|
||
* - Owned by DesktopManager.
|
||
* - Uses DesktopGrid/WidgetGrid for all geometry math.
|
||
* - Positions widgets inside each grid's `widgetContainer` (Gtk.Fixed).
|
||
*
|
||
* Coordinate model:
|
||
* - We store per-instance:
|
||
* * monitorIndex
|
||
* * normX, normY (0..1, normalized to grid.normalizedWidth/Height)
|
||
* * width, height (absolute pixels, widget-owned)
|
||
* - On layout changes, we rebuild a map of:
|
||
* monitorIndex -> { grid, widgetContainer }
|
||
* - To place an instance:
|
||
* localX = normX * grid.normalizedWidth
|
||
* localY = normY * grid.normalizedHeight
|
||
* widgetContainer.put(actor, localX, localY)
|
||
*/
|
||
export {WidgetManager};
|
||
|
||
const WIDGETS_STATE_SCHEMA_VERSION = 2;
|
||
|
||
const WidgetManager = class {
|
||
constructor(desktopManager) {
|
||
this._desktopManager = desktopManager;
|
||
this.Enums = desktopManager.Enums;
|
||
this._preferences = desktopManager.Prefs;
|
||
this._desktopIconsUtil = desktopManager.DesktopIconsUtil;
|
||
this._widgetRegistry = new WidgetRegistry(this._desktopIconsUtil);
|
||
|
||
// monitorIndex -> { grid, widgetContainer }
|
||
this._surfaces = new Map();
|
||
|
||
// instanceId -> {
|
||
// instanceId,
|
||
// widgetId,
|
||
// kind,
|
||
// monitorIndex,
|
||
// normX,
|
||
// normY,
|
||
// width,
|
||
// height,
|
||
// actor,
|
||
// config,
|
||
// }
|
||
this._instances = new Map();
|
||
this._selectedWidget = null;
|
||
this._chrome = null;
|
||
this.closeButton = null;
|
||
this.prefsButton = null;
|
||
this._selectedInstanceId = null;
|
||
this._webWidgetContext = null;
|
||
|
||
// When true, suppress emitting stateChanged events
|
||
this._suppressStateEvents = false;
|
||
this._loadStatePromise = null;
|
||
this._pendingLoadState = null;
|
||
|
||
this._addActions();
|
||
|
||
// loadState is handled during startup and by Preferences; avoid
|
||
// overlapping loads during construction.
|
||
}
|
||
|
||
clearFromGrids() {
|
||
for (const inst of this._instances.values()) {
|
||
const parent = inst.actor?.get_parent?.();
|
||
if (parent?.remove)
|
||
parent.remove(inst.actor);
|
||
}
|
||
|
||
for (const surface of this._surfaces.values())
|
||
this._teardownSurface(surface);
|
||
|
||
this._surfaces.clear();
|
||
}
|
||
|
||
stopWidgetDisplay() {
|
||
for (const surface of this._surfaces.values())
|
||
surface.grid.lowerWidgetContainer();
|
||
|
||
this.clearFromGrids();
|
||
|
||
for (const inst of this._instances.values()) {
|
||
if (inst.host && typeof inst.host.destroy === 'function')
|
||
inst.host.destroy();
|
||
|
||
inst.host = null;
|
||
inst.actor = null;
|
||
}
|
||
|
||
this._stopWebkitIfUnneeded();
|
||
|
||
this._stateChanged();
|
||
}
|
||
|
||
async startWidgetDisplay(desktops, params) {
|
||
await this.loadState(
|
||
this._preferences.widgetState
|
||
);
|
||
|
||
await this.applyLayoutChange(desktops, params);
|
||
}
|
||
|
||
/**
|
||
* Called by DesktopManager from applyDesktopLayoutChange().
|
||
*
|
||
* @param {Array<object>} desktops - the same array WindowManager uses,
|
||
* containing DesktopGrid/WidgetGrid instances for each monitor.
|
||
*
|
||
* @param {object} changeInfo - layout change info:
|
||
* {
|
||
* redisplay: boolean,
|
||
* monitorschanged: boolean,
|
||
* gridschanged: boolean,
|
||
* }
|
||
*/
|
||
async applyLayoutChange(desktops, changeInfo) {
|
||
if (!changeInfo?.redisplay)
|
||
return;
|
||
|
||
this._rebuildSurfacesFrom(desktops);
|
||
this._detachInstancesWithoutSurface();
|
||
await this._reattachAllInstances();
|
||
this._stopWebkitIfUnneeded();
|
||
}
|
||
|
||
handleWidgetContainerLayerChange(monitorIndex, onTop) {
|
||
const surface = this._surfaces.get(monitorIndex);
|
||
if (!surface)
|
||
return;
|
||
|
||
this._updateAddWidgetButtonVisibility(surface, onTop);
|
||
this._updateGridToggleButtonVisibility(surface, onTop);
|
||
this._raiseAddButton(surface);
|
||
this._raiseGridToggleButton(surface);
|
||
this._updateWidgetLayerChange(monitorIndex, onTop);
|
||
|
||
if (!onTop) {
|
||
if (surface.gridToggleButton) {
|
||
surface.gridToggleButton.set_active(false);
|
||
}
|
||
|
||
surface.grid.widgetGridEnabled = false;
|
||
surface.grid.updateOverlay();
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// Public instance API
|
||
// =====================================================================
|
||
|
||
/**
|
||
* High-level helper: create a new instance for a widget from the registry.
|
||
*
|
||
* @param {string} widgetId - ID from WidgetRegistry (usually folder name).
|
||
* @param {object} opts
|
||
* {
|
||
* monitorIndex?: number, // optional, defaults to first available
|
||
* x?: number, // local coords in widgetContainer space
|
||
* y?: number,
|
||
* width?: number, // override defaultWidth/defaultHeight
|
||
* height?: number,
|
||
* }
|
||
*
|
||
* Returns the created instance object or null.
|
||
*/
|
||
async createInstanceForWidget(widgetId, opts = {}) {
|
||
if (!this._preferences.showDesktopWidgets)
|
||
return null;
|
||
|
||
if (!widgetId) {
|
||
console.error('createInstanceForWidget: missing widgetId');
|
||
return null;
|
||
}
|
||
|
||
if (!this._widgetRegistry) {
|
||
console.error('createInstanceForWidget: widgetRegistry missing');
|
||
return null;
|
||
}
|
||
|
||
// 1. Load descriptor (or create a fallback one)
|
||
let descriptor = null;
|
||
try {
|
||
descriptor = await this._widgetRegistry.getDescriptor(widgetId);
|
||
} catch (e) {
|
||
console.error(`Descriptor load failed for ${widgetId}:`, e);
|
||
}
|
||
|
||
let kind = 'html';
|
||
if (!descriptor) {
|
||
console.warn(
|
||
`createInstanceForWidget: no descriptor for ${widgetId},` +
|
||
' using fallback html kind'
|
||
);
|
||
} else {
|
||
kind = descriptor.kind || 'html';
|
||
}
|
||
|
||
// 2. Choose monitor index: caller hint or first available surface
|
||
let monitorIndex = opts.monitorIndex;
|
||
if (monitorIndex === undefined || monitorIndex === null) {
|
||
const iter = this._surfaces.keys().next();
|
||
monitorIndex = !iter.done ? iter.value : 0;
|
||
}
|
||
|
||
const surface = this._surfaces.get(monitorIndex);
|
||
if (!surface) {
|
||
console.error(
|
||
`createInstanceForWidget: invalid monitorIndex ${monitorIndex}`
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const {grid} = surface;
|
||
|
||
// 3. Size from opts or registry defaults
|
||
const width = opts.width ??
|
||
descriptor?.defaultWidth ??
|
||
200;
|
||
|
||
const height = opts.height ??
|
||
descriptor?.defaultHeight ??
|
||
150;
|
||
|
||
// 4. Compute placement
|
||
let x = opts.x;
|
||
let y = opts.y;
|
||
|
||
if (x === undefined || y === undefined) {
|
||
const wNorm = grid.normalizedWidth;
|
||
const hNorm = grid.normalizedHeight;
|
||
|
||
x = Math.max(0, (wNorm - width) / 2);
|
||
y = Math.max(0, (hNorm - height) / 3);
|
||
}
|
||
|
||
// 5. Create the actual instance (generates UUID, attaches actor)
|
||
const instance = this._createInstance(
|
||
widgetId,
|
||
monitorIndex,
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
descriptor?.defaultConfig ?? {},
|
||
kind,
|
||
descriptor
|
||
);
|
||
|
||
if (!instance)
|
||
return null;
|
||
|
||
const created = await this._ensureInstanceActor(instance);
|
||
|
||
if (!created)
|
||
return null;
|
||
|
||
this._positionInstanceActor(instance);
|
||
|
||
// Persist creation
|
||
this._stateChanged();
|
||
|
||
return instance;
|
||
}
|
||
|
||
removeInstance(instanceId) {
|
||
this._removeActor(instanceId);
|
||
this._stateChanged();
|
||
}
|
||
|
||
deleteSelectedInstance() {
|
||
if (!this._selectedInstanceId)
|
||
return false;
|
||
|
||
const toRemove = this._selectedInstanceId;
|
||
|
||
// Clear selection first so CSS + chrome are detached.
|
||
this.selectInstance(null);
|
||
this.removeInstance(toRemove);
|
||
|
||
this._stopWebkitIfUnneeded();
|
||
|
||
return true;
|
||
}
|
||
|
||
setInstanceFrame(instanceId, x, y, width = null, height = null) {
|
||
const inst = this._instances.get(instanceId);
|
||
if (!inst)
|
||
return;
|
||
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
if (!surface)
|
||
return;
|
||
|
||
const {grid} = surface;
|
||
|
||
const [normX, normY] = grid.getNormalizedCoordinates(x, y);
|
||
|
||
const EPSILON = 1e-4;
|
||
const normXChanged = Math.abs(inst.normX - normX) > EPSILON;
|
||
const normYChanged = Math.abs(inst.normY - normY) > EPSILON;
|
||
let sizeChanged = false;
|
||
|
||
if (width !== null && width !== inst.width) {
|
||
inst.width = width;
|
||
sizeChanged = true;
|
||
}
|
||
|
||
if (height !== null && height !== inst.height) {
|
||
inst.height = height;
|
||
sizeChanged = true;
|
||
}
|
||
|
||
// Don't reposition if nothing changed, to avoid unnecessary state
|
||
// updates that write the json file with new postion triggering UI
|
||
// to refresh.
|
||
if (!normXChanged && !normYChanged && !sizeChanged)
|
||
return;
|
||
|
||
inst.normX = normX;
|
||
inst.normY = normY;
|
||
|
||
this._positionInstanceActor(inst);
|
||
}
|
||
|
||
/*
|
||
* Compute the current absolute frame for an instance based on
|
||
* stored normX/normY + width/height and the grid's normalized size.
|
||
*
|
||
* Returns coordinates in the local coordinate space of the
|
||
* widgetContainer.
|
||
*
|
||
*/
|
||
getInstanceFrame(instanceId) {
|
||
const inst = this._instances.get(instanceId);
|
||
if (!inst)
|
||
return null;
|
||
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
if (!surface) {
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width: inst.width,
|
||
height: inst.height,
|
||
};
|
||
}
|
||
|
||
const {grid} = surface;
|
||
|
||
let [x, y] = grid.setNormalizedCoordinates(inst.normX, inst.normY);
|
||
|
||
const wNorm = grid.normalizedWidth;
|
||
const hNorm = grid.normalizedHeight;
|
||
|
||
const w = inst.width;
|
||
const h = inst.height;
|
||
|
||
// Clamp to stay inside the grid's usable area
|
||
let clamped = false;
|
||
if (wNorm > 0 && w <= wNorm) {
|
||
if (x + w > wNorm) {
|
||
x = wNorm - w;
|
||
clamped = true;
|
||
}
|
||
if (x < 0) {
|
||
x = 0;
|
||
clamped = true;
|
||
}
|
||
}
|
||
|
||
if (hNorm > 0 && h <= hNorm) {
|
||
if (y + h > hNorm) {
|
||
y = hNorm - h;
|
||
clamped = true;
|
||
}
|
||
if (y < 0) {
|
||
y = 0;
|
||
clamped = true;
|
||
}
|
||
}
|
||
|
||
return {x, y, width: w, height: h, clamped};
|
||
}
|
||
|
||
get instances() {
|
||
return this._instances;
|
||
}
|
||
|
||
getInstance(instanceId) {
|
||
return this._instances.get(instanceId) || null;
|
||
}
|
||
|
||
getSelectedInstanceId() {
|
||
return this._selectedInstanceId;
|
||
}
|
||
|
||
clearSelectedInstance() {
|
||
const oldInst = this._instances.get(this._selectedInstanceId);
|
||
|
||
if (oldInst?.actor) {
|
||
const ctx = oldInst.actor.get_style_context();
|
||
ctx.remove_class('ding-widget-selected');
|
||
}
|
||
|
||
this._selectedInstanceId = null;
|
||
this._detachChrome();
|
||
this._updateWidgetsSelectionState();
|
||
}
|
||
|
||
selectInstance(instanceId) {
|
||
if (this._selectedInstanceId &&
|
||
this._selectedInstanceId !== instanceId
|
||
) {
|
||
const oldInst = this._instances.get(this._selectedInstanceId);
|
||
if (oldInst?.actor) {
|
||
const ctx = oldInst.actor.get_style_context();
|
||
ctx.remove_class('ding-widget-selected');
|
||
this._webWidgetContext?.closePreferencesIfAny();
|
||
}
|
||
}
|
||
|
||
this._selectedInstanceId = instanceId || null;
|
||
|
||
if (!instanceId) {
|
||
this._detachChrome();
|
||
this._updateWidgetsSelectionState();
|
||
this._webWidgetContext?.closePreferencesIfAny();
|
||
return;
|
||
}
|
||
|
||
const inst = this._instances.get(instanceId);
|
||
if (!inst?.actor || inst._isAddButton || inst._isGridToggleButton) {
|
||
this._selectedInstanceId = null;
|
||
this._detachChrome();
|
||
this._updateWidgetsSelectionState();
|
||
this._webWidgetContext?.closePreferencesForInstance();
|
||
return;
|
||
}
|
||
|
||
const ctx = inst.actor.get_style_context();
|
||
ctx.add_class('ding-widget-selected');
|
||
|
||
this._raiseInstance(inst);
|
||
|
||
if (typeof inst.actor.grab_focus === 'function')
|
||
inst.actor.grab_focus();
|
||
|
||
this._ensureChrome();
|
||
this._attachChromeToInstance(inst);
|
||
this._updateWidgetsSelectionState();
|
||
}
|
||
|
||
hideSelectionChromeDuringDrag() {
|
||
if (this._chrome) {
|
||
for (const btn of this._chrome)
|
||
btn.hide();
|
||
}
|
||
|
||
if (this._selectedInstanceId) {
|
||
const inst = this._instances.get(this._selectedInstanceId);
|
||
if (inst?.actor) {
|
||
const ctx = inst.actor.get_style_context();
|
||
ctx.remove_class('ding-widget-selected');
|
||
}
|
||
}
|
||
}
|
||
|
||
updateSelectionChromePositionFor(instanceId) {
|
||
if (!instanceId || instanceId !== this._selectedInstanceId)
|
||
return;
|
||
|
||
const inst = this._instances.get(instanceId);
|
||
if (!inst)
|
||
return;
|
||
|
||
const ctx = inst.actor.get_style_context();
|
||
ctx.add_class('ding-widget-selected');
|
||
|
||
this._ensureChrome();
|
||
this._attachChromeToInstance(inst);
|
||
}
|
||
|
||
async listAvailableWidgets() {
|
||
if (!this._widgetRegistry)
|
||
return [];
|
||
|
||
const widgets = await this._widgetRegistry.listWidgets().catch(e => {
|
||
console.error('WidgetManager: listAvailableWidgets failed:', e);
|
||
return [];
|
||
});
|
||
|
||
return widgets;
|
||
}
|
||
|
||
onThemeChanged() {
|
||
const theme = this._preferences.darkmode ? 'dark' : 'light';
|
||
|
||
for (const inst of this._instances.values())
|
||
this._updateTheme(inst, theme);
|
||
}
|
||
|
||
onAnimationChanged() {
|
||
const reducedMotion = !this._preferences.globalAnimations;
|
||
|
||
for (const inst of this._instances.values())
|
||
this._updateAnimation(inst, reducedMotion);
|
||
}
|
||
|
||
// =====================================================================
|
||
// Widget state persistence API
|
||
// =====================================================================
|
||
|
||
/*
|
||
* Load widget instances from a JSON-compatible object.
|
||
*
|
||
* Schema:
|
||
* {
|
||
* version: 2,
|
||
* instances: [
|
||
* {
|
||
* instanceId, widgetId, kind,
|
||
* monitorIndex, normX, normY,
|
||
* width, height,
|
||
* config: { ... } // author-defined fields
|
||
* prefsUri: string|null,
|
||
* hasPreferences: boolean,
|
||
* hasBackend: boolean,
|
||
* webConsent: boolean|null,
|
||
* backendConsent: boolean|null,
|
||
* },
|
||
* ...
|
||
* ]
|
||
* }
|
||
*
|
||
*
|
||
* @param {object} state - JSON-compatible object as described above.
|
||
*
|
||
* @returns {void}
|
||
*
|
||
* It is called by Preferences when it reads the saved state from disk.
|
||
* as well as by DesktopManager when it starts as well from this
|
||
* constructor.
|
||
*
|
||
* It is also called by Preferences when the user changes widget state.
|
||
*
|
||
* As the state read from disk is asynchronous, this method may be called
|
||
* before the widget registry is loaded, with null, so we need to handle
|
||
* that case gracefully.
|
||
*
|
||
* Method is idempotent; it will not remove instances that are not in the
|
||
* input state, and it will not add instances that are already present.
|
||
*
|
||
* It also has to deal with null, undefined, or missing fields gracefully.
|
||
*/
|
||
async loadState(state) {
|
||
if (this._loadStatePromise) {
|
||
this._pendingLoadState = state;
|
||
return this._loadStatePromise;
|
||
}
|
||
|
||
this._loadStatePromise = this._loadStateInner(state);
|
||
|
||
try {
|
||
await this._loadStatePromise;
|
||
} finally {
|
||
this._loadStatePromise = null;
|
||
if (this._pendingLoadState) {
|
||
const pending = this._pendingLoadState;
|
||
this._pendingLoadState = null;
|
||
await this.loadState(pending);
|
||
}
|
||
}
|
||
}
|
||
|
||
async _loadStateInner(state) {
|
||
if (!state || typeof state !== 'object')
|
||
return;
|
||
|
||
const schemaVersion =
|
||
Number.isFinite(state.version) ? state.version : 1;
|
||
|
||
if (schemaVersion < WIDGETS_STATE_SCHEMA_VERSION) {
|
||
console.warn(
|
||
`WidgetManager loadState: state version ${schemaVersion} ` +
|
||
`(current ${WIDGETS_STATE_SCHEMA_VERSION}); migrating`
|
||
);
|
||
state = await this._migrateToCurrentVersion(state);
|
||
}
|
||
|
||
|
||
if (!Array.isArray(state.instances))
|
||
return;
|
||
|
||
const prevSelection = this._selectedInstanceId;
|
||
|
||
// Avoid emitting stateChanged while rebuilding from persisted state.
|
||
// Only suppress around positioning, so we don't block saves for long
|
||
// async operations (e.g., consent prompts).
|
||
const previousSuppressionState = this._suppressStateEvents;
|
||
|
||
try {
|
||
const seen = new Set();
|
||
|
||
for (const instData of state.instances) {
|
||
if (!instData.instanceId || !instData.widgetId)
|
||
continue;
|
||
|
||
try {
|
||
let instance = this._instances.get(instData.instanceId);
|
||
|
||
if (instance) {
|
||
instance.widgetId = instData.widgetId;
|
||
instance.monitorIndex = instData.monitorIndex ?? 0;
|
||
instance.kind = instData.kind ?? 'html';
|
||
instance.normX = instData.normX ?? 0;
|
||
instance.normY = instData.normY ?? 0;
|
||
instance.width = instData.width ?? 200;
|
||
instance.height = instData.height ?? 150;
|
||
instance.config = instData.config ?? {};
|
||
instance.prefsUri = instData.prefsUri ?? null;
|
||
instance.hasPreferences =
|
||
instData.hasPreferences ?? !!instance.prefsUri;
|
||
instance.hasBackend = instData.hasBackend;
|
||
instance.webConsent = instData.webConsent ?? null;
|
||
instance.backendConsent =
|
||
instData.backendConsent ?? null;
|
||
} else {
|
||
instance = {
|
||
instanceId: instData.instanceId,
|
||
widgetId: instData.widgetId,
|
||
monitorIndex: instData.monitorIndex ?? 0,
|
||
kind: instData.kind ?? 'html',
|
||
normX: instData.normX ?? 0,
|
||
normY: instData.normY ?? 0,
|
||
width: instData.width ?? 200,
|
||
height: instData.height ?? 150,
|
||
actor: null,
|
||
config: instData.config ?? {},
|
||
prefsUri: instData.prefsUri ?? null,
|
||
hasPreferences:
|
||
instData.hasPreferences ?? !!instData.prefsUri,
|
||
hasBackend: instData.hasBackend ?? false,
|
||
webConsent: instData.webConsent ?? null,
|
||
backendConsent: instData.backendConsent ?? null,
|
||
};
|
||
|
||
this._instances.set(instance.instanceId, instance);
|
||
}
|
||
|
||
seen.add(instance.instanceId);
|
||
|
||
const surface = this._surfaces.get(instance.monitorIndex);
|
||
if (surface) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const created =
|
||
await this._ensureInstanceActor(instance);
|
||
|
||
if (!created)
|
||
continue;
|
||
|
||
const prev = this._suppressStateEvents;
|
||
this._suppressStateEvents = true;
|
||
this._positionInstanceActor(instance);
|
||
this._suppressStateEvents = prev;
|
||
}
|
||
} catch (e) {
|
||
console.error(
|
||
'WidgetManager loadState failed for instance:',
|
||
{
|
||
instanceId: instData.instanceId,
|
||
widgetId: instData.widgetId,
|
||
monitorIndex: instData.monitorIndex,
|
||
kind: instData.kind,
|
||
},
|
||
e
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const instanceId of [...this._instances.keys()]) {
|
||
const instance = this._instances.get(instanceId);
|
||
if (instance?._isAddButton || instance?._isGridToggleButton)
|
||
continue;
|
||
|
||
if (seen.has(instanceId))
|
||
continue;
|
||
|
||
this._removeActor(instanceId);
|
||
}
|
||
|
||
if (prevSelection && this._instances.has(prevSelection))
|
||
this.selectInstance(prevSelection);
|
||
else
|
||
this.selectInstance(null);
|
||
} catch (e) {
|
||
console.error('WidgetManager loadState failed:', e);
|
||
} finally {
|
||
this._suppressStateEvents = previousSuppressionState;
|
||
}
|
||
}
|
||
|
||
updateInstanceConfig(instanceId, newConfig) {
|
||
const inst = this._instances.get(instanceId);
|
||
if (!inst)
|
||
return;
|
||
|
||
inst.config = newConfig;
|
||
this._stateChanged();
|
||
}
|
||
|
||
/**
|
||
* Notify Preferences that widget state has changed.
|
||
* Triggers async write to $XDG_DATA_HOME/<app-id>/widgets.json
|
||
*/
|
||
_stateChanged() {
|
||
if (this._suppressStateEvents || !this._preferences)
|
||
return;
|
||
|
||
const stateObj = this.exportState();
|
||
this._preferences.widgetState = stateObj;
|
||
}
|
||
|
||
/**
|
||
* Compute a per-instance Z index from the current GTK child order of each
|
||
* widgetContainer. Lower index = deeper, higher index = closer to front.
|
||
*
|
||
* This does not mutate any internal state; it only observes the widget
|
||
* hierarchy. Add buttons and non-widget actors are skipped.
|
||
*
|
||
* @returns {Map<string, number>} instanceId -> zIndex
|
||
*/
|
||
_computeZIndexByInstanceId() {
|
||
const zIndexByInstanceId = new Map();
|
||
|
||
for (const surface of this._surfaces.values()) {
|
||
const widgetContainer = surface.widgetContainer;
|
||
if (!widgetContainer ||
|
||
typeof widgetContainer.get_first_child !== 'function')
|
||
continue;
|
||
|
||
let child = widgetContainer.get_first_child();
|
||
let i = 0;
|
||
|
||
while (child) {
|
||
const instanceId = child.widgetInstanceId;
|
||
if (instanceId) {
|
||
const inst = this._instances.get(instanceId);
|
||
if (inst && !inst._isAddButton && !inst._isGridToggleButton) {
|
||
if (!zIndexByInstanceId.has(instanceId))
|
||
zIndexByInstanceId.set(instanceId, i);
|
||
}
|
||
}
|
||
|
||
if (!child.get_next_sibling)
|
||
break;
|
||
|
||
child = child.get_next_sibling();
|
||
i++;
|
||
}
|
||
}
|
||
|
||
return zIndexByInstanceId;
|
||
}
|
||
|
||
/**
|
||
* Return a list of content widget instances sorted for export:
|
||
* 1) By monitorIndex (to keep per-monitor grouping stable).
|
||
* 2) By stacking index within that monitor, derived from GTK.
|
||
*
|
||
* This does not change the instances or maps; it only sorts a local array.
|
||
*
|
||
* @param {Map<string, number>} zIndexByInstanceId
|
||
* @returns {Array<object>} sorted instance objects
|
||
*/
|
||
_sortedInstancesForExport(zIndexByInstanceId) {
|
||
const instances = [];
|
||
|
||
for (const inst of this._instances.values()) {
|
||
if (inst._isAddButton || inst._isGridToggleButton)
|
||
continue;
|
||
|
||
instances.push(inst);
|
||
}
|
||
|
||
instances.sort((a, b) => {
|
||
// First group by monitorIndex
|
||
const ma = a.monitorIndex ?? 0;
|
||
const mb = b.monitorIndex ?? 0;
|
||
|
||
if (ma !== mb)
|
||
return ma - mb;
|
||
|
||
// Then by z-index within that monitor, derived from GTK
|
||
const za = zIndexByInstanceId.has(a.instanceId)
|
||
? zIndexByInstanceId.get(a.instanceId)
|
||
: -1;
|
||
const zb = zIndexByInstanceId.has(b.instanceId)
|
||
? zIndexByInstanceId.get(b.instanceId)
|
||
: -1;
|
||
|
||
return za - zb;
|
||
});
|
||
|
||
return instances;
|
||
}
|
||
|
||
/**
|
||
* Export the full current widget state as a JSON-compatible object.
|
||
*
|
||
* The saved schema is identical to loadState():
|
||
* {
|
||
* version: 1,
|
||
* instances: [
|
||
* { instanceId, widgetId, kind, monitorIndex,
|
||
* normX, normY, width, height, config }
|
||
* ]
|
||
* }
|
||
* */
|
||
exportState() {
|
||
const zIndexByInstanceId = this._computeZIndexByInstanceId();
|
||
const sortedInstances =
|
||
this._sortedInstancesForExport(zIndexByInstanceId);
|
||
|
||
const out = {
|
||
version: WIDGETS_STATE_SCHEMA_VERSION,
|
||
instances: [],
|
||
};
|
||
|
||
for (const inst of sortedInstances) {
|
||
out.instances.push({
|
||
instanceId: inst.instanceId,
|
||
widgetId: inst.widgetId,
|
||
monitorIndex: inst.monitorIndex,
|
||
kind: inst.kind,
|
||
normX: inst.normX,
|
||
normY: inst.normY,
|
||
width: inst.width,
|
||
height: inst.height,
|
||
config: inst.config ?? {},
|
||
prefsUri: inst.prefsUri ?? null,
|
||
hasPreferences: !!inst.hasPreferences,
|
||
hasBackend: !!inst.hasBackend,
|
||
webConsent: inst.webConsent ?? null,
|
||
backendConsent: inst.backendConsent ?? null,
|
||
});
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
async _migrateToCurrentVersion(state) {
|
||
if (!state || typeof state !== 'object')
|
||
return {version: WIDGETS_STATE_SCHEMA_VERSION, instances: []};
|
||
|
||
const schemaVersion =
|
||
Number.isFinite(state.version) ? state.version : 1;
|
||
|
||
if (!Array.isArray(state.instances))
|
||
state.instances = [];
|
||
|
||
let migrated = false;
|
||
|
||
if (schemaVersion < 2) {
|
||
// v1 -> v2: instances gain hasBackend.
|
||
// Compute it once from the widget manifest (descriptor) and persist.
|
||
// Schema v2+: backend capability is stored per instance (hasBackend)
|
||
// and is resolved once at creation or migration time.
|
||
|
||
for (const instData of state.instances) {
|
||
if (!instData || typeof instData !== 'object')
|
||
continue;
|
||
|
||
let hasBackend = false;
|
||
|
||
try {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const desc = await this._widgetRegistry.getDescriptor(
|
||
instData.widgetId
|
||
);
|
||
|
||
hasBackend = !!desc?.hasBackend;
|
||
} catch (e) {
|
||
// If registry lookup fails, default false (safe).
|
||
hasBackend = false;
|
||
}
|
||
|
||
instData.hasBackend = hasBackend;
|
||
migrated = true;
|
||
}
|
||
|
||
state.version = 2;
|
||
migrated = true;
|
||
}
|
||
|
||
if (migrated && this._preferences) {
|
||
// Persist the migrated file state as-is (do NOT call exportState() here).
|
||
this._preferences.widgetState = state;
|
||
}
|
||
|
||
return state;
|
||
}
|
||
|
||
|
||
// =====================================================================
|
||
// Internal helpers
|
||
// =====================================================================
|
||
_createInstance(widgetId, monitorIndex, x, y, width, height,
|
||
config = {}, kind, descriptor = null) {
|
||
const surface = this._surfaces.get(monitorIndex);
|
||
if (!surface) {
|
||
console.error(
|
||
`WidgetManager.createInstance:
|
||
unknown monitorIndex ${monitorIndex}`
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const {grid} = surface;
|
||
|
||
// Convert local coords to normalized using existing grid plumbing.
|
||
// This uses normalizedWidth/Height internally.
|
||
const [normX, normY] = grid.getNormalizedCoordinates(x, y);
|
||
|
||
const instanceId = GLib.uuid_string_random();
|
||
|
||
const instance = {
|
||
instanceId,
|
||
widgetId,
|
||
monitorIndex,
|
||
normX,
|
||
normY,
|
||
width,
|
||
height,
|
||
actor: null,
|
||
config,
|
||
kind,
|
||
hasBackend: descriptor?.hasBackend ?? !!descriptor?.backend ?? false,
|
||
prefsUri: descriptor?.prefs ?? null,
|
||
hasPreferences: !!descriptor?.prefs,
|
||
};
|
||
|
||
this._instances.set(instanceId, instance);
|
||
return instance;
|
||
}
|
||
|
||
_rebuildSurfacesFrom(desktops) {
|
||
const existingAddButtons = new Map();
|
||
const existingGridToggleButtons = new Map();
|
||
for (const inst of this._instances.values()) {
|
||
if (inst._isAddButton)
|
||
existingAddButtons.set(inst.monitorIndex, inst);
|
||
else if (inst._isGridToggleButton)
|
||
existingGridToggleButtons.set(inst.monitorIndex, inst);
|
||
}
|
||
|
||
for (const surface of this._surfaces.values())
|
||
this._teardownSurface(surface);
|
||
|
||
this._surfaces.clear();
|
||
|
||
for (const grid of desktops) {
|
||
if (!grid)
|
||
continue;
|
||
|
||
const monitorIndex = grid.monitorIndex;
|
||
if (monitorIndex === undefined || monitorIndex === null)
|
||
continue;
|
||
|
||
const widgetContainer = grid.widgetContainer;
|
||
if (!widgetContainer) {
|
||
console.error(
|
||
`WidgetManager: grid for monitorIndex ${monitorIndex
|
||
} is missing widgetContainer`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const surface = {
|
||
grid,
|
||
widgetContainer,
|
||
monitorIndex,
|
||
addButton: null,
|
||
gridToggleButton: null,
|
||
};
|
||
|
||
this._surfaces.set(monitorIndex, surface);
|
||
|
||
const existingAddInst = existingAddButtons.get(monitorIndex);
|
||
this._ensureAddWidgetButton(surface, existingAddInst);
|
||
const existingGridToggleInst =
|
||
existingGridToggleButtons.get(monitorIndex);
|
||
this._ensureGridToggleButton(surface, existingGridToggleInst);
|
||
}
|
||
}
|
||
|
||
_teardownSurface(surface) {
|
||
if (!surface)
|
||
return;
|
||
|
||
if (surface.addButton) {
|
||
const parent = surface.addButton.get_parent?.();
|
||
if (parent?.remove)
|
||
parent.remove(surface.addButton);
|
||
|
||
surface.addButton = null;
|
||
}
|
||
|
||
const addButtonInstanceId =
|
||
this._getAddButtonInstanceId(surface.monitorIndex);
|
||
const addInst = addButtonInstanceId
|
||
? this._instances.get(addButtonInstanceId)
|
||
: null;
|
||
if (addInst?._isAddButton)
|
||
addInst.actor = null;
|
||
else if (addButtonInstanceId)
|
||
this._instances.delete(addButtonInstanceId);
|
||
|
||
if (surface.gridToggleButton) {
|
||
const parent = surface.gridToggleButton.get_parent?.();
|
||
if (parent?.remove)
|
||
parent.remove(surface.gridToggleButton);
|
||
|
||
surface.gridToggleButton = null;
|
||
}
|
||
|
||
const gridToggleButtonInstanceId =
|
||
this._getGridToggleButtonInstanceId(surface.monitorIndex);
|
||
const gridToggleInst = gridToggleButtonInstanceId
|
||
? this._instances.get(gridToggleButtonInstanceId)
|
||
: null;
|
||
if (gridToggleInst?._isGridToggleButton)
|
||
gridToggleInst.actor = null;
|
||
else if (gridToggleButtonInstanceId)
|
||
this._instances.delete(gridToggleButtonInstanceId);
|
||
}
|
||
|
||
_getAddButtonInstanceId(monitorIndex) {
|
||
if (monitorIndex === undefined || monitorIndex === null)
|
||
return null;
|
||
return `__ding-add-button-${monitorIndex}`;
|
||
}
|
||
|
||
_getGridToggleButtonInstanceId(monitorIndex) {
|
||
if (monitorIndex === undefined || monitorIndex === null)
|
||
return null;
|
||
return `__ding-widget-grid-toggle-button-${monitorIndex}`;
|
||
}
|
||
|
||
_ensureAddWidgetButton(surface, existingInst = null) {
|
||
if (!surface?.widgetContainer)
|
||
return;
|
||
|
||
const instanceId = this._getAddButtonInstanceId(surface.monitorIndex);
|
||
|
||
if (surface.addButton) {
|
||
this._raiseAddButton(surface);
|
||
return;
|
||
}
|
||
|
||
const button = new Gtk.Button();
|
||
button.set_name('ding-widget-add-button');
|
||
button.set_can_focus(false);
|
||
button.set_focus_on_click(false);
|
||
button.set_tooltip_text(_('Add Widget'));
|
||
button.connect(
|
||
'clicked',
|
||
() => this.openAddWidgetDialog(
|
||
null,
|
||
surface.monitorIndex
|
||
).catch(logError)
|
||
);
|
||
|
||
const icon = Gtk.Image.new_from_icon_name('list-add-symbolic');
|
||
button.set_child(icon);
|
||
|
||
button.widgetInstanceId = instanceId;
|
||
|
||
surface.widgetContainer.put(button, 0, 0);
|
||
surface.addButton = button;
|
||
|
||
const inst = existingInst ?? {
|
||
instanceId,
|
||
widgetId: '__ding-add-button',
|
||
monitorIndex: surface.monitorIndex,
|
||
kind: 'chrome',
|
||
normX: 0,
|
||
normY: 0,
|
||
width: 48,
|
||
height: 48,
|
||
actor: button,
|
||
config: {},
|
||
_isAddButton: true,
|
||
};
|
||
|
||
inst.actor = button;
|
||
inst.monitorIndex = surface.monitorIndex;
|
||
this._instances.set(instanceId, inst);
|
||
|
||
if (!existingInst) {
|
||
const [defaultX, defaultY] =
|
||
this._getDefaultAddButtonPosition(surface, inst);
|
||
|
||
this.setInstanceFrame(instanceId, defaultX, defaultY, inst.width,
|
||
inst.height
|
||
);
|
||
} else {
|
||
this._positionInstanceActor(inst);
|
||
}
|
||
this._updateAddWidgetButtonVisibility(surface);
|
||
this._raiseAddButton(surface);
|
||
}
|
||
|
||
_ensureGridToggleButton(surface, existingInst = null) {
|
||
if (!surface?.widgetContainer)
|
||
return;
|
||
|
||
const instanceId = this._getGridToggleButtonInstanceId(surface.monitorIndex);
|
||
|
||
if (surface.gridToggleButton) {
|
||
this._raiseGridToggleButton(surface);
|
||
return;
|
||
}
|
||
|
||
const gridToggleButton = new Gtk.ToggleButton();
|
||
gridToggleButton.set_name('ding-widget-grid-toggle-button');
|
||
gridToggleButton.set_can_focus(false);
|
||
gridToggleButton.set_focus_on_click(false);
|
||
gridToggleButton.set_tooltip_text(_('Toggle Widget Grid'));
|
||
|
||
const gridIcon = Gtk.Image.new_from_icon_name('view-grid-symbolic');
|
||
gridToggleButton.set_child(gridIcon);
|
||
gridToggleButton.set_active(false);
|
||
|
||
gridToggleButton.widgetInstanceId = instanceId;
|
||
|
||
gridToggleButton.connect('toggled', (btn) => {
|
||
surface.grid.widgetGridEnabled = btn.get_active();
|
||
surface.grid.updateOverlay();
|
||
});
|
||
|
||
surface.widgetContainer.put(gridToggleButton, 0, 0);
|
||
surface.gridToggleButton = gridToggleButton;
|
||
|
||
const inst = existingInst ?? {
|
||
instanceId,
|
||
widgetId: '__ding-widget-grid-toggle-button',
|
||
monitorIndex: surface.monitorIndex,
|
||
kind: 'chrome',
|
||
normX: 0,
|
||
normY: 0,
|
||
width: 48,
|
||
height: 48,
|
||
actor: gridToggleButton,
|
||
config: {},
|
||
_isGridToggleButton: true,
|
||
};
|
||
inst.actor = gridToggleButton;
|
||
inst.monitorIndex = surface.monitorIndex;
|
||
this._instances.set(instanceId, inst);
|
||
|
||
if (!existingInst) {
|
||
const [defaultX, defaultY] =
|
||
this._getDefaultGridToggleButtonPosition(surface, inst);
|
||
|
||
this.setInstanceFrame(instanceId, defaultX, defaultY, inst.width,
|
||
inst.height
|
||
);
|
||
} else {
|
||
this._positionInstanceActor(inst);
|
||
}
|
||
|
||
this._updateGridToggleButtonVisibility(surface);
|
||
this._raiseGridToggleButton(surface);
|
||
}
|
||
|
||
_updateAddWidgetButtonVisibility(surface, forcedState = null) {
|
||
if (!surface?.addButton)
|
||
return;
|
||
|
||
const shouldShow = typeof forcedState === 'boolean'
|
||
? forcedState
|
||
: Boolean(surface.grid?.isWidgetContainerOnTop?.());
|
||
|
||
surface.addButton.set_visible(shouldShow);
|
||
surface.addButton.set_sensitive(shouldShow);
|
||
}
|
||
|
||
_updateGridToggleButtonVisibility(surface, forcedState = null) {
|
||
if (!surface?.gridToggleButton)
|
||
return;
|
||
|
||
const shouldShow = typeof forcedState === 'boolean'
|
||
? forcedState
|
||
: Boolean(surface.grid?.isWidgetContainerOnTop?.());
|
||
|
||
surface.gridToggleButton.set_visible(shouldShow);
|
||
surface.gridToggleButton.set_sensitive(shouldShow);
|
||
}
|
||
|
||
_raiseAddButton(surface) {
|
||
if (!surface?.addButton || !surface.widgetContainer)
|
||
return;
|
||
|
||
const parent = surface.addButton.get_parent?.();
|
||
if (!parent || parent !== surface.widgetContainer)
|
||
return;
|
||
|
||
try {
|
||
surface.addButton.insert_before(parent, null);
|
||
} catch (e) {
|
||
console.error('WidgetManager: failed to raise add button:', e);
|
||
}
|
||
}
|
||
|
||
_raiseGridToggleButton(surface) {
|
||
if (!surface?.gridToggleButton || !surface.widgetContainer)
|
||
return;
|
||
|
||
const parent = surface.gridToggleButton.get_parent?.();
|
||
if (!parent || parent !== surface.widgetContainer)
|
||
return;
|
||
|
||
try {
|
||
surface.gridToggleButton.insert_before(parent, null);
|
||
} catch (e) {
|
||
console
|
||
.error('WidgetManager: failed to raise grid toggle button:', e);
|
||
}
|
||
}
|
||
|
||
_getDefaultAddButtonPosition(surface, inst) {
|
||
const grid = surface.grid;
|
||
if (!grid)
|
||
return [0, 0];
|
||
|
||
const width = grid.normalizedWidth;
|
||
const height = grid.normalizedHeight;
|
||
const buttonWidth = inst?.width ?? 48;
|
||
const buttonHeight = inst?.height ?? 48;
|
||
const margin = 32;
|
||
|
||
const direction =
|
||
surface.widgetContainer.get_direction?.() ?? Gtk.TextDirection.NONE;
|
||
const isRTL = direction === Gtk.TextDirection.RTL;
|
||
|
||
const maxX = Math.max(0, width - buttonWidth);
|
||
const desiredX = isRTL
|
||
? width - buttonWidth - margin
|
||
: margin;
|
||
const x = Math.max(0, Math.min(desiredX, maxX));
|
||
|
||
const maxY = Math.max(0, height - buttonHeight);
|
||
const desiredY = height - buttonHeight - margin;
|
||
const y = Math.max(0, Math.min(desiredY, maxY));
|
||
|
||
return [x, y];
|
||
}
|
||
|
||
_getDefaultGridToggleButtonPosition(surface, inst) {
|
||
const grid = surface.grid;
|
||
if (!grid)
|
||
return [0, 0];
|
||
|
||
const addButtonInstanceId =
|
||
this._getAddButtonInstanceId(surface.monitorIndex);
|
||
|
||
const addButtonInst = addButtonInstanceId
|
||
? this._instances.get(addButtonInstanceId)
|
||
: null;
|
||
|
||
if (!addButtonInst) {
|
||
return [0, 0];
|
||
}
|
||
|
||
const width = grid.normalizedWidth;
|
||
const buttonWidth = inst?.width ?? 48;
|
||
const buttonHeight = inst?.height ?? 48;
|
||
const spacing = 16;
|
||
|
||
const addButtonX = addButtonInst.normX * grid.normalizedWidth;
|
||
const addButtonY = addButtonInst.normY * grid.normalizedHeight;
|
||
|
||
const x = Math.max(0, Math.min(addButtonX, width - buttonWidth));
|
||
const desiredY = addButtonY - buttonHeight - spacing;
|
||
const y = Math.max(0, desiredY);
|
||
|
||
return [x, y];
|
||
}
|
||
|
||
_detachInstancesWithoutSurface() {
|
||
for (const inst of this._instances.values()) {
|
||
if (inst?._isAddButton || inst?._isGridToggleButton)
|
||
continue;
|
||
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
if (surface)
|
||
continue;
|
||
|
||
const parent = inst.actor?.get_parent?.();
|
||
if (parent?.remove)
|
||
parent.remove(inst.actor);
|
||
|
||
if (inst.host && typeof inst.host.destroy === 'function')
|
||
inst.host.destroy();
|
||
|
||
inst.actor = null;
|
||
inst.host = null;
|
||
}
|
||
}
|
||
|
||
async _reattachAllInstances() {
|
||
if (!this._preferences.showDesktopWidgets)
|
||
return;
|
||
|
||
for (const inst of this._instances.values()) {
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
|
||
if (!surface)
|
||
continue;
|
||
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const created = await this._ensureInstanceActor(inst);
|
||
|
||
if (!created)
|
||
continue;
|
||
|
||
this._positionInstanceActor(inst);
|
||
|
||
// Use optional chaining because requestRender()
|
||
// currently exists only on HTML hosts.
|
||
inst.host?.requestRender?.().catch(e => logError(e));
|
||
}
|
||
}
|
||
|
||
async _ensureInstanceActor(inst) {
|
||
if (!this._preferences.showDesktopWidgets)
|
||
return false;
|
||
|
||
if (inst.actor)
|
||
return true;
|
||
|
||
const created = await this._createActorForInstance(inst);
|
||
|
||
if (!created)
|
||
return false;
|
||
|
||
inst.actor.widgetInstanceId = inst.instanceId;
|
||
return true;
|
||
}
|
||
|
||
async _createActorForInstance(inst) {
|
||
const frame = this.getInstanceFrame(inst.instanceId);
|
||
if (!frame)
|
||
return false;
|
||
|
||
// this can be re-entrant for html widgets due to consent checks
|
||
if (inst.actor)
|
||
return true;
|
||
|
||
let actor = null;
|
||
|
||
if (inst.kind === 'html') {
|
||
const proceed = await this._checkConsentForInstance(inst);
|
||
|
||
if (!proceed)
|
||
return false;
|
||
|
||
// this can be re-entrant for html widgets due to consent checks
|
||
if (inst.actor)
|
||
return true;
|
||
|
||
const webCtx = this._ensureWebWidgetContext();
|
||
const HostClass =
|
||
inst.hasBackend ? HtmlWidgetHostWithBackend : HtmlWidgetHost;
|
||
|
||
const host = new HostClass({
|
||
instanceId: inst.instanceId,
|
||
widgetId: inst.widgetId,
|
||
frameRect: frame,
|
||
widgetRegistry: this._widgetRegistry,
|
||
webContext: webCtx,
|
||
});
|
||
|
||
inst.host = host;
|
||
actor = host.actor;
|
||
} else if (inst.kind === 'gtk') {
|
||
actor = this._createGtkActorForInstance(inst, frame);
|
||
} else {
|
||
console.error(
|
||
`WidgetManager: unknown widget kind for ${inst.widgetId}`
|
||
);
|
||
}
|
||
|
||
if (!actor)
|
||
return false;
|
||
|
||
actor.set_name('ding-widget');
|
||
actor.set_overflow(Gtk.Overflow.HIDDEN);
|
||
actor.set_focusable(true);
|
||
|
||
actor.instanceId = inst.instanceId;
|
||
inst.actor = actor;
|
||
return true;
|
||
}
|
||
|
||
async _checkConsentForInstance(inst) {
|
||
if (inst._consentInProgress)
|
||
return false;
|
||
|
||
inst._consentInProgress = true;
|
||
|
||
try {
|
||
let updateState = false;
|
||
let removeInstance = false;
|
||
|
||
if (inst.webConsent !== true) {
|
||
updateState = true;
|
||
|
||
const ok = await this._askWebConsent(inst);
|
||
|
||
if (!ok)
|
||
removeInstance = true;
|
||
else
|
||
inst.webConsent = true;
|
||
}
|
||
|
||
if (inst.hasBackend &&
|
||
inst.backendConsent !== true &&
|
||
!removeInstance
|
||
) {
|
||
updateState = true;
|
||
|
||
const ok = await this._askBackendConsent(inst);
|
||
|
||
if (!ok)
|
||
removeInstance = true;
|
||
else
|
||
inst.backendConsent = true;
|
||
}
|
||
|
||
if (removeInstance)
|
||
this._removeActor(inst.instanceId);
|
||
|
||
// IMPORTANT: during loadState writes are suppressed, so if we changed
|
||
// consent OR removed an instance, force a state write once.
|
||
const stateDirty = updateState || removeInstance;
|
||
|
||
if (stateDirty) {
|
||
const previousSuppressionState = this._suppressStateEvents;
|
||
this._suppressStateEvents = false;
|
||
this._stateChanged();
|
||
this._suppressStateEvents = previousSuppressionState;
|
||
}
|
||
|
||
return !removeInstance;
|
||
} catch (e) {
|
||
console.error('WidgetManager: _checkConsentForInstance failed:', e);
|
||
return false;
|
||
} finally {
|
||
inst._consentInProgress = false;
|
||
}
|
||
}
|
||
|
||
_removeActor(instanceId) {
|
||
const inst = this._instances.get(instanceId);
|
||
if (!inst)
|
||
return;
|
||
|
||
const parent = inst.actor?.get_parent?.();
|
||
if (parent?.remove)
|
||
parent.remove(inst.actor);
|
||
|
||
if (inst.host && typeof inst.host.destroy === 'function')
|
||
inst.host.destroy();
|
||
|
||
if (typeof inst.actor?.destroy === 'function')
|
||
inst.actor.destroy();
|
||
|
||
this._instances.delete(instanceId);
|
||
inst.actor = null;
|
||
}
|
||
|
||
_positionInstanceActor(inst) {
|
||
if (!inst.actor)
|
||
return;
|
||
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
if (!surface)
|
||
return;
|
||
|
||
const frame = this.getInstanceFrame(inst.instanceId);
|
||
if (!frame)
|
||
return;
|
||
|
||
const {widgetContainer} = surface;
|
||
const {x, y} = frame;
|
||
const isMove = !!inst.actor.get_parent();
|
||
|
||
if (frame.clamped || isMove) {
|
||
const [normX, normY] = surface.grid.getNormalizedCoordinates(x, y);
|
||
const EPSILON = 1e-4;
|
||
|
||
if (Math.abs(inst.normX - normX) > EPSILON ||
|
||
Math.abs(inst.normY - normY) > EPSILON) {
|
||
inst.normX = normX;
|
||
inst.normY = normY;
|
||
}
|
||
|
||
this._stateChanged();
|
||
}
|
||
|
||
if (isMove)
|
||
widgetContainer.move(inst.actor, x, y);
|
||
else
|
||
widgetContainer.put(inst.actor, x, y);
|
||
}
|
||
|
||
_ensureChrome() {
|
||
if (this._chrome)
|
||
return;
|
||
|
||
this.closeButton = new Gtk.Button();
|
||
this.closeButton.set_name('ding-widget-close-button');
|
||
this.closeButton.set_can_focus(false);
|
||
this.closeButton.set_focus_on_click(false);
|
||
|
||
const img = Gtk.Image.new_from_icon_name('window-close-symbolic');
|
||
this.closeButton.set_child(img);
|
||
|
||
this.closeButton.connect('clicked',
|
||
this.deleteSelectedInstance.bind(this)
|
||
);
|
||
|
||
this.prefsButton = new Gtk.Button();
|
||
this.prefsButton.set_name('ding-widget-prefs-button');
|
||
this.prefsButton.set_can_focus(false);
|
||
this.prefsButton.set_focus_on_click(false);
|
||
this.prefsButton.set_tooltip_text(_('Widget preferences'));
|
||
|
||
const prefsImg = Gtk.Image.new_from_icon_name('emblem-system-symbolic');
|
||
this.prefsButton.set_child(prefsImg);
|
||
|
||
this.prefsButton.connect('clicked',
|
||
this._openPreferencesForSelectedInstance.bind(this)
|
||
);
|
||
|
||
this._chrome = new Set();
|
||
this._chrome.add(this.closeButton);
|
||
this._chrome.add(this.prefsButton);
|
||
}
|
||
|
||
_attachChromeToInstance(inst) {
|
||
if (!this._chrome)
|
||
return;
|
||
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
if (!surface)
|
||
return;
|
||
|
||
const {widgetContainer} = surface;
|
||
const frame = this.getInstanceFrame(inst.instanceId);
|
||
if (!frame)
|
||
return;
|
||
|
||
let allocWidth = frame.width;
|
||
const alloc = inst.actor?.get_allocation?.();
|
||
if (alloc)
|
||
allocWidth = alloc.width;
|
||
|
||
const size = 28;
|
||
const gap = 6;
|
||
const margin = 8;
|
||
|
||
const showPrefs = inst.hasPreferences;
|
||
const buttonCount = showPrefs ? 2 : 1;
|
||
const totalWidth = buttonCount * size + (buttonCount - 1) * gap;
|
||
|
||
const centerX = frame.x + allocWidth / 2;
|
||
const buttonsX = centerX - totalWidth / 2;
|
||
|
||
let yPos;
|
||
const yPosUp = frame.y - size - margin;
|
||
const yPosDown = frame.y + frame.height + margin;
|
||
if (yPosUp < margin) {
|
||
yPos = yPosDown;
|
||
} else {
|
||
yPos = yPosUp;
|
||
}
|
||
|
||
const prefsOldParent = this.prefsButton.get_parent();
|
||
if (prefsOldParent && prefsOldParent !== widgetContainer)
|
||
prefsOldParent.remove(this.prefsButton);
|
||
const closeOldParent = this.closeButton.get_parent();
|
||
if (closeOldParent && closeOldParent !== widgetContainer)
|
||
closeOldParent.remove(this.closeButton);
|
||
|
||
if (showPrefs) {
|
||
if (!this.prefsButton.get_parent())
|
||
widgetContainer.put(this.prefsButton, buttonsX, yPos);
|
||
else
|
||
widgetContainer.move(this.prefsButton, buttonsX, yPos);
|
||
this.prefsButton.show();
|
||
} else {
|
||
this.prefsButton.hide();
|
||
}
|
||
|
||
const closeX = showPrefs ? (buttonsX + size + gap) : buttonsX;
|
||
if (!this.closeButton.get_parent())
|
||
widgetContainer.put(this.closeButton, closeX, yPos);
|
||
else
|
||
widgetContainer.move(this.closeButton, closeX, yPos);
|
||
this.closeButton.show();
|
||
|
||
this._raiseChromeButtons(surface);
|
||
}
|
||
|
||
_detachChrome() {
|
||
if (!this._chrome)
|
||
return;
|
||
|
||
for (const btn of this._chrome) {
|
||
const parent = btn.get_parent();
|
||
if (parent)
|
||
parent.remove(btn);
|
||
}
|
||
}
|
||
|
||
_raiseChromeButtons(surface) {
|
||
if (!this._chrome || !surface?.widgetContainer)
|
||
return;
|
||
|
||
for (const btn of this._chrome) {
|
||
const parent = btn.get_parent?.();
|
||
if (!parent || parent !== surface.widgetContainer)
|
||
continue;
|
||
|
||
try {
|
||
btn.insert_before(parent, null);
|
||
} catch (e) {
|
||
console.error('WidgetManager: failed to raise chrome button:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
_openPreferencesForSelectedInstance() {
|
||
const selectedId = this._selectedInstanceId;
|
||
const inst = selectedId
|
||
? this._instances.get(selectedId)
|
||
: null;
|
||
|
||
if (!inst || !inst.hasPreferences) {
|
||
console.warn('No widget selected or widget has no preferences UI.');
|
||
return;
|
||
}
|
||
|
||
// Delegate everything to WebWidgetContext
|
||
const webCtx = this._ensureWebWidgetContext();
|
||
webCtx.openPreferencesForInstance(selectedId, inst.prefsUri);
|
||
}
|
||
|
||
_raiseInstance(inst) {
|
||
if (!inst || !inst.actor)
|
||
return;
|
||
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
if (!surface || !surface.widgetContainer)
|
||
return;
|
||
|
||
const parent = inst.actor.get_parent?.();
|
||
if (!parent || parent !== surface.widgetContainer)
|
||
return;
|
||
|
||
try {
|
||
inst.actor.insert_before(parent, null);
|
||
} catch (e) {
|
||
console.error('WidgetManager: failed to raise instance:', e);
|
||
}
|
||
|
||
this._raiseAddButton(surface);
|
||
this._raiseGridToggleButton(surface);
|
||
}
|
||
|
||
_getWidgetKind(_widgetId) {
|
||
// Stub for future GTK widgets. For now, everything is HTML.
|
||
//
|
||
return 'html';
|
||
}
|
||
|
||
_updateWidgetLayerChange(monitorIndex, onTop) {
|
||
for (const inst of this._instances.values()) {
|
||
if (inst.monitorIndex !== monitorIndex ||
|
||
!inst.actor)
|
||
continue;
|
||
|
||
this._sendLayerStateToInstance(inst, onTop);
|
||
}
|
||
}
|
||
|
||
_sendLayerStateToInstance(inst, onTop) {
|
||
// ToDo: GTK Widget layer change;
|
||
if (inst.kind === 'html' && inst.host)
|
||
this._webWidgetContext?.updateHtmlWidgetLayer(inst, onTop);
|
||
}
|
||
|
||
_updateWidgetsSelectionState() {
|
||
for (const inst of this._instances.values()) {
|
||
const selected = inst.instanceId === this._selectedInstanceId;
|
||
// To Do: GTK Widget seleted state
|
||
if (inst.kind === 'html' && inst.actor && inst.host)
|
||
this._webWidgetContext?.updateHtmlWidgetSelected(inst, selected);
|
||
}
|
||
}
|
||
|
||
_updateTheme(inst, theme) {
|
||
// To Do: GTK Widget layer change
|
||
if (inst.kind === 'html' && inst.actor && inst.host)
|
||
this._webWidgetContext?.updateHtmlWidgetTheme(inst, theme);
|
||
}
|
||
|
||
_updateAnimation(inst, reducedMotion) {
|
||
// To Do : Gtk Widget layer change
|
||
if (inst.kind === 'html' && inst.actor && inst.host)
|
||
this._webWidgetContext?.updateHtmlWidgetAnimation(inst, reducedMotion);
|
||
}
|
||
|
||
_getLocale() {
|
||
try {
|
||
const langs = GLib.get_language_names?.();
|
||
if (langs && langs.length)
|
||
return langs[0];
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
|
||
return 'en_US';
|
||
}
|
||
|
||
_getDirectionForActor(actor) {
|
||
let direction = 'ltr';
|
||
|
||
try {
|
||
if (actor && typeof actor.get_direction === 'function') {
|
||
const dir = actor.get_direction();
|
||
if (dir === Gtk.TextDirection.RTL)
|
||
direction = 'rtl';
|
||
}
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
|
||
return direction;
|
||
}
|
||
|
||
computeHostStateForInstance(inst) {
|
||
const actor = inst.actor;
|
||
const selected = inst.instanceId === this._selectedInstanceId;
|
||
|
||
const surface = this._surfaces.get(inst.monitorIndex);
|
||
const grid = surface?.grid;
|
||
|
||
const editMode = !!grid?.isWidgetContainerOnTop?.();
|
||
const theme = this._preferences.darkmode ? 'dark' : 'light';
|
||
const reducedMotion = !this._preferences.globalAnimations;
|
||
const locale = this._getLocale();
|
||
const direction = this._getDirectionForActor(actor);
|
||
|
||
return {
|
||
editMode,
|
||
selected,
|
||
theme,
|
||
reducedMotion,
|
||
direction,
|
||
locale,
|
||
};
|
||
}
|
||
|
||
/* ====================================================================
|
||
* --- IGNORE ---
|
||
* GTK widget support stub (future)
|
||
* --- IGNORE ---
|
||
* ===================================================================== */
|
||
|
||
_createGtkActorForInstance(inst, _frame) {
|
||
// GTK stub for later: we could create a native Gtk.Widget here, e.g.:
|
||
console.warn(
|
||
`WidgetManager: GTK widget kind requested for ${inst.widgetId}, ` +
|
||
'but GTK widget support is not implemented yet'
|
||
);
|
||
return null;
|
||
}
|
||
|
||
/* ====================================================================
|
||
* WebKit WebContext HTML widget support
|
||
* ===================================================================== */
|
||
_ensureWebWidgetContext() {
|
||
if (!this._webWidgetContext) {
|
||
this._webWidgetContext =
|
||
new WebWidgetContext(this._desktopManager, this);
|
||
}
|
||
|
||
return this._webWidgetContext;
|
||
}
|
||
|
||
|
||
_stopWebkitIfUnneeded() {
|
||
if (!this._webWidgetContext)
|
||
return;
|
||
|
||
// We prune aggressively, there may be no host, add button has
|
||
// no isAlive(). Look only for html hosts
|
||
const hasHtmlWidget =
|
||
Array.from(this._instances.values())
|
||
.some(inst => inst.host?.isAlive?.());
|
||
|
||
if (hasHtmlWidget)
|
||
return;
|
||
|
||
this._webWidgetContext.destroy();
|
||
this._webWidgetContext = null;
|
||
}
|
||
|
||
/* =====================================================================
|
||
* Widget Picker UI
|
||
* ===================================================================== */
|
||
|
||
async openAddWidgetDialog(parentWindow = null, monitorIndex = null) {
|
||
if (!this._widgetRegistry) {
|
||
console.error('openAddWidgetDialog: widgetRegistry missing');
|
||
return null;
|
||
}
|
||
|
||
let widgets;
|
||
try {
|
||
widgets = await this._widgetRegistry.listWidgets();
|
||
} catch (e) {
|
||
console.error('openAddWidgetDialog: listWidgets failed:', e);
|
||
return null;
|
||
}
|
||
|
||
// Sort by display name
|
||
widgets.sort((a, b) => {
|
||
const nameA = (a.name || a.id || '').toLowerCase();
|
||
const nameB = (b.name || b.id || '').toLowerCase();
|
||
return nameA.localeCompare(nameB);
|
||
});
|
||
|
||
if (!parentWindow)
|
||
parentWindow = this._desktopManager.mainApp.get_active_window();
|
||
|
||
const {window, list, addButton, cancelButton} =
|
||
this._createWidgetPickerWindow(parentWindow, widgets);
|
||
|
||
const resultPromise = new Promise(resolve => {
|
||
cancelButton.connect('clicked', () => {
|
||
window.close();
|
||
resolve(null);
|
||
});
|
||
|
||
addButton.connect('clicked', async () => {
|
||
const row = list.get_selected_row();
|
||
if (!row || !row._widgetId) {
|
||
window.close();
|
||
resolve(null);
|
||
return;
|
||
}
|
||
|
||
let created = null;
|
||
try {
|
||
created = await this.createInstanceForWidget(row._widgetId, {
|
||
monitorIndex,
|
||
});
|
||
} catch (e) {
|
||
console.error(
|
||
'openAddWidgetDialog: createInstanceForWidget failed:',
|
||
e
|
||
);
|
||
}
|
||
|
||
window.close();
|
||
resolve(created);
|
||
});
|
||
|
||
// Double-clicking a row also activates "Add"
|
||
list.connect('row-activated', () => {
|
||
addButton.activate();
|
||
});
|
||
|
||
// If user closes via window close button / Esc
|
||
window.connect('close-request', () => {
|
||
resolve(null);
|
||
return false; // allow close
|
||
});
|
||
|
||
const shortcutController = new Gtk.ShortcutController({
|
||
propagation_phase: Gtk.PropagationPhase.CAPTURE,
|
||
});
|
||
|
||
shortcutController.add_shortcut(new Gtk.Shortcut({
|
||
trigger: Gtk.ShortcutTrigger.parse_string('Escape'),
|
||
action: Gtk.CallbackAction.new(() => {
|
||
window.close();
|
||
return true;
|
||
}),
|
||
}));
|
||
window.add_controller(shortcutController);
|
||
});
|
||
|
||
window.present();
|
||
const createdInstance = await resultPromise;
|
||
return createdInstance;
|
||
}
|
||
|
||
_createWidgetPickerWindow(parentWindow, widgets) {
|
||
const builder =
|
||
Gtk.Builder
|
||
.new_from_resource('/com/desktop/ding/ui/ding-widget-chooser.ui');
|
||
|
||
/** @type {Adw.Window} */
|
||
const window = builder.get_object('widget_picker_window');
|
||
/** @type {Gtk.ListBox} */
|
||
const list = builder.get_object('widget_list');
|
||
/** @type {Gtk.Button} */
|
||
const addButton = builder.get_object('add_button');
|
||
/** @type {Gtk.Button} */
|
||
const cancelButton = builder.get_object('cancel_button');
|
||
|
||
if (parentWindow)
|
||
window.set_transient_for(parentWindow);
|
||
|
||
// Populate rows from registry
|
||
for (const desc of widgets) {
|
||
const row = this._createWidgetRow(desc);
|
||
list.append(row);
|
||
}
|
||
|
||
// Select first by default
|
||
const firstRow = list.get_row_at_index(0);
|
||
if (firstRow)
|
||
list.select_row(firstRow);
|
||
|
||
return {window, list, addButton, cancelButton};
|
||
}
|
||
|
||
_createWidgetRow(desc) {
|
||
const row = new Gtk.ListBoxRow();
|
||
row._widgetId = desc.id;
|
||
|
||
const box = new Gtk.Box({
|
||
orientation: Gtk.Orientation.VERTICAL,
|
||
spacing: 2,
|
||
});
|
||
|
||
const titleLabel = new Gtk.Label({
|
||
label: desc.name || desc.id,
|
||
xalign: 0,
|
||
});
|
||
|
||
const subtitleParts = [];
|
||
|
||
if (desc.kind) {
|
||
if (desc.kind === 'html')
|
||
subtitleParts.push(_('HTML widget'));
|
||
else if (desc.kind === 'gtk')
|
||
subtitleParts.push(_('GTK widget'));
|
||
else
|
||
subtitleParts.push(desc.kind);
|
||
}
|
||
|
||
if (desc.category)
|
||
subtitleParts.push(desc.category);
|
||
|
||
if (desc.isUser)
|
||
subtitleParts.push(_('User'));
|
||
|
||
const subtitle = subtitleParts.join(' · ');
|
||
|
||
const subtitleLabel = new Gtk.Label({
|
||
label: subtitle,
|
||
xalign: 0,
|
||
});
|
||
subtitleLabel.add_css_class('dim-label');
|
||
|
||
box.append(titleLabel);
|
||
if (subtitle)
|
||
box.append(subtitleLabel);
|
||
|
||
row.set_child(box);
|
||
return row;
|
||
}
|
||
|
||
_addActions() {
|
||
const addWidgetAction = Gio.SimpleAction.new('addWidget', null);
|
||
addWidgetAction.connect('activate', () => {
|
||
const parentWindow =
|
||
this._desktopManager.mainApp.get_active_window();
|
||
|
||
let monitorIndex = null;
|
||
|
||
if (parentWindow) {
|
||
const surface = parentWindow.get_surface();
|
||
const display = surface?.get_display?.();
|
||
const monitor = display?.get_monitor_at_surface?.(surface);
|
||
const monitors = display?.get_monitors?.();
|
||
const count = monitors?.get_n_items?.() ?? 0;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
if (monitors.get_item?.(i) === monitor) {
|
||
monitorIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (monitorIndex === null)
|
||
return;
|
||
|
||
// Ensure widget layers are visible before adding a widget.
|
||
this._desktopManager.windowManager?.raiseWidgetLayers();
|
||
|
||
this.openAddWidgetDialog(parentWindow, monitorIndex)
|
||
.catch(logError);
|
||
});
|
||
this._desktopManager.mainApp.add_action(addWidgetAction);
|
||
|
||
const showGridAction = Gio.SimpleAction.new('toggleWidgetGrid', null);
|
||
showGridAction.connect('activate', () => {
|
||
const parentWindow =
|
||
this._desktopManager.mainApp.get_active_window();
|
||
|
||
let monitorIndex = null;
|
||
|
||
if (parentWindow) {
|
||
const surface = parentWindow.get_surface();
|
||
const display = surface?.get_display?.();
|
||
const monitor = display?.get_monitor_at_surface?.(surface);
|
||
const monitors = display?.get_monitors?.();
|
||
const count = monitors?.get_n_items?.() ?? 0;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
if (monitors.get_item?.(i) === monitor) {
|
||
monitorIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (monitorIndex === null)
|
||
return;
|
||
|
||
let gridToggleButton = null;
|
||
const instanceId =
|
||
this._getGridToggleButtonInstanceId(monitorIndex);
|
||
|
||
const inst = instanceId ? this._instances.get(instanceId) : null;
|
||
gridToggleButton = inst?.actor ?? null;
|
||
|
||
if (!gridToggleButton)
|
||
return;
|
||
|
||
// Ensure widget layers are visible before showingt widget grid.
|
||
this._desktopManager.windowManager?.raiseWidgetLayers();
|
||
gridToggleButton?.activate();
|
||
});
|
||
this._desktopManager.mainApp.add_action(showGridAction);
|
||
|
||
const closeWidget = Gio.SimpleAction.new('closeWidget', null);
|
||
closeWidget.connect('activate', this.deleteSelectedInstance.bind(this));
|
||
this._desktopManager.mainApp.add_action(closeWidget);
|
||
}
|
||
|
||
/* =====================================================================
|
||
* Widget Consent UI
|
||
* ===================================================================== */
|
||
|
||
_asyncAskYesNo(heading, body, bodyUseMarkup = false) {
|
||
const parentWindow = this._desktopManager.mainApp.get_active_window();
|
||
const yesLabel = _('Allow');
|
||
const noLabel = _('Cancel');
|
||
|
||
return new Promise(resolve => {
|
||
const dlg = new Adw.AlertDialog();
|
||
dlg.set_presentation_mode(Adw.DialogPresentationMode.FLOATING);
|
||
dlg.set_follows_content_size(false);
|
||
dlg.set_content_width(500);
|
||
|
||
dlg.set_heading(heading);
|
||
dlg.set_body_use_markup(bodyUseMarkup);
|
||
dlg.set_body(body);
|
||
dlg.add_response('no', noLabel);
|
||
dlg.add_response('yes', yesLabel);
|
||
dlg.set_default_response('no');
|
||
dlg.set_close_response('no');
|
||
if (typeof dlg.set_prefer_wide_layout === 'function')
|
||
dlg.set_prefer_wide_layout(true);
|
||
|
||
dlg.set_response_appearance(
|
||
'yes',
|
||
Adw.ResponseAppearance.SUGGESTED
|
||
);
|
||
|
||
dlg.set_response_appearance(
|
||
'no',
|
||
Adw.ResponseAppearance.DEFAULT
|
||
);
|
||
|
||
const shortcutController = new Gtk.ShortcutController({
|
||
propagation_phase: Gtk.PropagationPhase.CAPTURE,
|
||
});
|
||
shortcutController.add_shortcut(new Gtk.Shortcut({
|
||
trigger: Gtk.ShortcutTrigger.parse_string('Escape'),
|
||
action: Gtk.CallbackAction.new(() => {
|
||
dlg.close();
|
||
return true;
|
||
}),
|
||
}));
|
||
dlg.add_controller(shortcutController);
|
||
|
||
dlg.connect('response', (_d, response) => {
|
||
resolve(response === 'yes');
|
||
});
|
||
|
||
dlg.present(parentWindow ?? null);
|
||
});
|
||
}
|
||
|
||
_describeCspProfileForHumans() {
|
||
const profile = this.Enums?.DEFAULT_CSP_PROFILE;
|
||
|
||
if (profile === this.Enums?.CspProfile?.STRICT) {
|
||
return {
|
||
name: _('Strict'),
|
||
summary: _(
|
||
'The widget runs in a tightly sandboxed web environment.\n\n' +
|
||
'• No external scripts or frames are allowed.\n' +
|
||
'• Network access is limited to secure (HTTPS) requests.\n' +
|
||
'• Only the widget’s own files and inline code may run.\n\n' +
|
||
'This is the safest option and is recommended for most widgets.'
|
||
),
|
||
};
|
||
}
|
||
|
||
if (profile === this.Enums?.CspProfile?.RELAXED) {
|
||
return {
|
||
name: _('Relaxed'),
|
||
summary: _(
|
||
'The widget is allowed broader web capabilities.\n\n' +
|
||
'• External scripts, styles, images, and frames from trusted websites may load.\n' +
|
||
'• Network access over HTTPS, WebSockets, and media streams is allowed.\n\n' +
|
||
'Use this only for widgets you trust.'
|
||
),
|
||
};
|
||
}
|
||
|
||
if (profile === this.Enums?.CspProfile?.DEV) {
|
||
return {
|
||
name: _('Development'),
|
||
summary: _(
|
||
'The widget runs with development-friendly web access.\n\n' +
|
||
'• Connections to local development servers (localhost) are allowed.\n' +
|
||
'• HTTP and WebSocket access may be permitted for testing.\n\n' +
|
||
'This mode is intended for development and debugging only.'
|
||
),
|
||
};
|
||
}
|
||
|
||
return {
|
||
name: String(profile ?? _('Default')),
|
||
summary: _(
|
||
'The widget runs with a predefined web security policy.\n\n' +
|
||
'Web access and capabilities are restricted according to the active policy.'
|
||
),
|
||
};
|
||
}
|
||
|
||
async _askWebConsent(inst) {
|
||
const widgetId = inst.widgetId;
|
||
const heading = _('Allow web content for {widgetId}?')
|
||
.replace('{widgetId}', widgetId);
|
||
const cspProfile = this._describeCspProfileForHumans();
|
||
const cspProfileName = GLib.markup_escape_text(cspProfile.name, -1);
|
||
const cspProfileSummary = GLib.markup_escape_text(
|
||
cspProfile.summary,
|
||
-1
|
||
);
|
||
const body =
|
||
// eslint-disable-next-line prefer-template
|
||
_('The widget you are adding may load web content from the internet.\n\n') +
|
||
_('This content is subject to the widget security policy:\n\n') +
|
||
`<span weight="ultrabold">${cspProfileName}</span>\n` +
|
||
`${cspProfileSummary}`;
|
||
|
||
|
||
const answer = await this._asyncAskYesNo(heading, body, true);
|
||
|
||
return answer;
|
||
}
|
||
|
||
async _askBackendConsent(inst) {
|
||
const widgetId = inst.widgetId;
|
||
let argvStr = '';
|
||
|
||
try {
|
||
const desc = await this._widgetRegistry.getDescriptor(inst.widgetId);
|
||
const spec = this._widgetRegistry.normalizeBackendSpec(desc, inst);
|
||
|
||
if (spec?.argv?.length) {
|
||
argvStr = spec.argv.map(a =>
|
||
/[\s"]/g.test(a) ? `"${a.replaceAll('"', '\\"')}"` : a
|
||
).join(' ');
|
||
}
|
||
} catch (e) {
|
||
// If we can’t resolve spec, keep message generic.
|
||
argvStr = '';
|
||
}
|
||
|
||
const body =
|
||
_('This widget, {widgetId} runs a background process on your computer.\n\n')
|
||
.replace('{widgetId}', widgetId) +
|
||
_('The backend runs with your normal user permissions, just like any other application you start.\n') +
|
||
_('It can access your files, system resources, and the network according to your user account permissions.\n\n') +
|
||
(argvStr
|
||
? `<b>${GLib.markup_escape_text(_('Command:'), -1)}</b>\n` +
|
||
`${GLib.markup_escape_text(argvStr, -1)}\n\n`
|
||
: '') +
|
||
_('Only allow this for widgets you implicitly trust.');
|
||
|
||
const answer = await this._asyncAskYesNo(
|
||
_('Allow widget backend?'),
|
||
body,
|
||
true);
|
||
|
||
return answer;
|
||
}
|
||
};
|