574 lines
17 KiB
JavaScript
574 lines
17 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/>.
|
|
*/
|
|
|
|
/*
|
|
* Layout:
|
|
* $XDG_DATA_HOME/<app-id>/widgets/<widgetId>/widget.json
|
|
* $XDG_DATA_DIRS/.../<app-id>/widgets/<widgetId>/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<Array<object>>}
|
|
*/
|
|
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<object|null>}
|
|
*/
|
|
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<Gio.File|null>}
|
|
*/
|
|
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/<app-id>/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, <dir>/<app-id>/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:
|
|
* <root>/<widgetId>/widget.json
|
|
*
|
|
* @param {Gio.File} root
|
|
* @param {boolean} isUser
|
|
* @param {Map<string,object>} 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 ?? '<unknown>'
|
|
);
|
|
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;
|
|
}
|
|
};
|