/* 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 . */ /* * Layout: * $XDG_DATA_HOME//widgets//widget.json * $XDG_DATA_DIRS/...//widgets//widget.json */ import {Gio, GLib} from '../dependencies/gi.js'; export {WidgetRegistry}; const WidgetRegistry = class { constructor(desktopIconsUtil) { this._util = desktopIconsUtil; this._appId = null; this._userRoot = null; this._systemRoots = []; // id -> descriptor this._widgets = new Map(); this._loaded = false; this._loadingPromise = null; this._initPaths(); this.preload(); } // --------------------------------------------------------------------- // Public API // --------------------------------------------------------------------- preload() { this._ensureLoadedAsync().catch(e => { console.error('WidgetRegistry.preload():', e); }); } /** * Get a snapshot of all known widgets. * * @returns {Promise>} */ async listWidgets() { await this._ensureLoadedAsync(); return Array.from(this._widgets.values()); } /** * Look up a widget descriptor by ID. * * Descriptor shape: * { * id: string, * name: string, * kind: 'html' | 'gtk', * dir: Gio.File, * manifestFile: Gio.File, * isUser: boolean, * defaultWidth: number | null, * defaultHeight: number | null, * defaultConfig: object | null, * "name_localized": { * "fr": "Horloge analogique" * }, * description: "A simple analog clock widget.", * "description_localized": { * "fr": "Un widget d'horloge analogique simple." * }, * category: "time", * author: "Sundeep Mediratta", * version: "1.0", * homepage: "https://…", * license: "GPL-3.0-or-later" * } * * @param {string} id Widget identifier * @returns {Promise} */ async getDescriptor(id) { if (!id) return null; await this._ensureLoadedAsync(); return this._widgets.get(id) ?? null; } /* * Get widget kind ('html' | 'gtk') * * @param {string} id Widget identifier * @returns {Promise<'html'|'gtk'>} */ async getKind(id) { const desc = await this.getDescriptor(id); return desc?.kind === 'gtk' ? 'gtk' : 'html'; } /** * For HTML widgets, return the entry file (always index.html). * * @param {string} id Widget identifier * @returns {Promise} */ async getHtmlEntryFile(id) { const desc = await this.getDescriptor(id); if (!desc || desc.kind !== 'html') return null; const file = desc.dir.get_child('index.html'); try { const info = file.query_info( 'standard::type', Gio.FileQueryInfoFlags.NONE, null ); if (info.get_file_type() === Gio.FileType.REGULAR) return file; } catch (e) { console.error( 'WidgetRegistry: getHtmlEntryFile failed for', id, e ); } return null; } reload() { this._loaded = false; this._loadingPromise = null; this._widgets.clear(); this.preload(); } // --------------------------------------------------------------------- // Internal // --------------------------------------------------------------------- _initPaths() { const mainApp = this._util.mainApp; const appId = mainApp?.get_application_id ? mainApp.get_application_id() : null; if (!appId) { console.error( 'WidgetRegistry: application-id not available; ' + 'widget paths will not be resolved.' ); return; } this._appId = appId; // User root: $XDG_DATA_HOME//widgets try { const appDataDir = this._util.getAppUserDataDir(); this._userRoot = appDataDir.get_child('widgets'); } catch (e) { console.warn('WidgetRegistry: failed to resolve user root:', e); } // System roots: for each XDG data dir, //widgets try { const systemBaseDirs = GLib.get_system_data_dirs(); this._systemRoots = systemBaseDirs.map(base => { return Gio.File.new_build_filenamev([ base, this._appId, 'widgets', ]); }); } catch (e) { console.warn('WidgetRegistry: failed to build system roots:', e); this._systemRoots = []; } } async _ensureLoadedAsync() { if (this._loaded) return; // Idempotent if (this._loadingPromise) { await this._loadingPromise; return; } const loadPromise = this._loadOnceAsync(); this._loadingPromise = loadPromise; try { await loadPromise; } finally { this._loadingPromise = null; } } async _loadOnceAsync() { const newMap = new Map(); this._loggedDuplicateIds = new Set(); if (this._userRoot) await this._scanRootAsync(this._userRoot, true, newMap); for (const root of this._systemRoots) // eslint-disable-next-line no-await-in-loop await this._scanRootAsync(root, false, newMap); this._widgets = newMap; this._loaded = true; } /** * Async scan of a single root dir: * //widget.json * * @param {Gio.File} root * @param {boolean} isUser * @param {Map} outMap */ async _scanRootAsync(root, isUser, outMap) { let info; try { info = root.query_info( 'standard::type', Gio.FileQueryInfoFlags.NONE, null ); } catch (e) { return; } if (info.get_file_type() !== Gio.FileType.DIRECTORY) return; let enumerator; try { enumerator = root.enumerate_children( 'standard::name,standard::type', Gio.FileQueryInfoFlags.NONE, null ); } catch (e) { console.error( 'WidgetRegistry: enumerate_children failed for', root.get_path?.(), e ); return; } try { while (true) { // eslint-disable-next-line no-await-in-loop const files = await this._nextFilesAsync(enumerator); if (!files.length) break; for (const finfo of files) { if (finfo.get_file_type() !== Gio.FileType.DIRECTORY) continue; const name = finfo.get_name(); const widgetDir = root.get_child(name); const manifestFile = widgetDir.get_child('widget.json'); let manifestInfo; try { manifestInfo = manifestFile.query_info( 'standard::type', Gio.FileQueryInfoFlags.NONE, null ); } catch (e) { continue; } if (manifestInfo.get_file_type() !== Gio.FileType.REGULAR) continue; const manifest = // eslint-disable-next-line no-await-in-loop await this._util.readJsonFile(manifestFile); if (!manifest) continue; // ID must come from widget.json and must be valid. const idRaw = typeof manifest.id === 'string' ? manifest.id.trim() : ''; const idOk = !!idRaw && /^[A-Za-z0-9._-]+$/.test(idRaw); if (!idOk) { const mf = manifestFile.get_path?.() ?? manifestFile.get_uri?.() ?? String(manifestFile); console.warn( `WidgetRegistry: rejecting widget with invalid id in ${mf}` ); continue; } const id = idRaw; const kind = manifest.kind === 'gtk' ? 'gtk' : 'html'; const displayName = manifest.name || id; const description = manifest.description || ''; const author = manifest.author || ''; const version = manifest.version || ''; const icon = manifest.icon || ''; const defaultWidth = Number.isFinite(manifest.defaultWidth) ? Math.max(1, Math.floor(manifest.defaultWidth)) : 260; const defaultHeight = Number.isFinite(manifest.defaultHeight) ? Math.max(1, Math.floor(manifest.defaultHeight)) : 160; const defaultConfig = this._isObject(manifest.defaultConfig) ? manifest.defaultConfig : {}; const prefs = typeof manifest.prefs === 'string' ? manifest.prefs : null; const backend = this._isObject(manifest.backend) ? manifest.backend : null; const desc = { id, kind, dir: widgetDir, manifestFile, isUser, displayName, description, author, version, icon, defaultWidth, defaultHeight, defaultConfig, prefs, backend, hasBackend: !!backend, }; // Resolve duplicates deterministically; // log each duplicated id only once. const existing = outMap.get(id); if (existing) { const replace = isUser && !existing.isUser; this._logDuplicateIds( id, existing, replace, widgetDir, isUser ); if (replace) outMap.set(id, desc); } else { outMap.set(id, desc); } } } } catch (e) { console.error( 'WidgetRegistry: error scanning root', root.get_path?.(), e ); } finally { try { enumerator.close(null); } catch (e) { console.error( 'WidgetRegistry: error closing enumerator', e ); } } } _isObject(object) { const isObject = object !== null && typeof object === 'object' && !Array.isArray(object) && Object.getPrototypeOf(object) === Object.prototype; return isObject; } _logDuplicateIds(id, existing, replace, widgetDir, isUser) { if (this._loggedDuplicateIds.has(id)) return; const toPathString = file => file?.get_path?.() ?? file?.get_uri?.() ?? String(file); const existingPath = toPathString(existing?.dir); const newPath = toPathString(widgetDir); const existingScope = existing?.isUser ? 'user' : 'system'; const newScope = isUser ? 'user' : 'system'; const action = replace ? 'using user override' : 'keeping first'; console.warn( `WidgetRegistry: duplicate widget id "${id}": ` + `${existingPath} (${existingScope}) vs ` + `${newPath} (${newScope}) — ${action}` ); this._loggedDuplicateIds.add(id); } _nextFilesAsync(enumerator) { const batchSize = 32; const cancelable = null; return new Promise((resolve, reject) => { enumerator.next_files_async( batchSize, GLib.PRIORITY_DEFAULT, cancelable, (src, res) => { try { const files = src.next_files_finish(res); resolve(files ?? []); } catch (e) { reject(e); } } ); }); } normalizeBackendSpec(desc, inst) { return this._normalizeBackendSpec(desc, inst); } _normalizeBackendSpec(desc, inst) { const b = desc?.backend; if (!b || typeof b !== 'object') return null; const dirFile = desc.dir; const dirPath = dirFile?.get_path?.(); if (!dirFile || !dirPath) return null; const argv = this._buildBackendArgv(b, dirFile); if (!argv?.length) { console.error( 'WidgetRegistry: backend argv missing/invalid for widget', desc?.id ?? '' ); return null; } const cwd = this._resolveBackendCwd(b, dirFile, dirPath); const envOverrides = b.env && typeof b.env === 'object' ? b.env : null; const env = { DING_WIDGET_ID: String(inst?.widgetId ?? ''), DING_INSTANCE_ID: String(inst?.instanceId ?? ''), }; if (envOverrides) { for (const [key, value] of Object.entries(envOverrides)) { if (typeof key !== 'string') continue; if (value === undefined) continue; env[key] = typeof value === 'string' ? value : String(value); } } return { argv, cwd, env, }; } _buildBackendArgv(backend, dirFile) { const cmd = backend?.command; if (typeof cmd !== 'string' || cmd.length === 0) return null; // eslint-disable-next-line no-nested-ternary const args = Array.isArray(backend.args) ? backend.args.filter(a => typeof a === 'string') : Array.isArray(backend.argv) ? backend.argv.filter(a => typeof a === 'string') : []; // Resolve argv[0] to an absolute path if it isn't already. let argv0 = null; if (cmd.startsWith('/')) { argv0 = cmd; } else if (cmd.includes('/')) { const f = dirFile.resolve_relative_path ? dirFile.resolve_relative_path(cmd) : dirFile.get_child(cmd); argv0 = f?.get_path?.() ?? null; } else { argv0 = GLib.find_program_in_path(cmd); if (!argv0) { const f = dirFile.resolve_relative_path ? dirFile.resolve_relative_path(cmd) : dirFile.get_child(cmd); argv0 = f?.get_path?.() ?? null; } } if (!argv0) return null; return [argv0, ...args]; } _resolveBackendCwd(backend, dirFile, fallbackDirPath) { const cwdRel = typeof backend?.cwd === 'string' && backend.cwd.length ? backend.cwd : '.'; const cwdFile = dirFile.resolve_relative_path ? dirFile.resolve_relative_path(cwdRel) : dirFile.get_child(cwdRel); return cwdFile?.get_path?.() ?? fallbackDirPath; } };