/* 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 . */ 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} 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//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} 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} zIndexByInstanceId * @returns {Array} 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') + `${cspProfileName}\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 ? `${GLib.markup_escape_text(_('Command:'), -1)}\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; } };