Files
LEDMatrix/web_interface/static/v3/js/plugins/install_manager.js
ChuckandClaude Opus 5.5 e32d177cbd fix(web-ui): Plugin Manager - enable aliased installs, Update All, on-demand modes, long installs, categories, GitHub-URL install (#746)
* fix(web-ui): Update All sends the live installed list and redraws the grid

updateAll() preferred PluginStateManager.installedPlugins over
window.installedPlugins. Only updateAll's own end-of-run refresh ever
fills PluginStateManager, so from the second run on it sent the first
run's plugins: one uninstalled since failed with "plugin not found" and
one installed since was never updated. That refresh also only replaced
window.installedPlugins, so the installed cards and the Updates badge
kept offering "Update to vX" for what had just been updated.

Read window.installedPlugins, the list plugins_manager.js republishes
after every install, uninstall and refresh, keeping PluginStateManager
as the fallback for a page without it, and refresh through
pluginManager.loadInstalledPlugins(true), which redraws the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): list each plugin's display modes in /plugins/installed

The on-demand modal fills its Display Mode select from
plugin.display_modes, but /plugins/installed never sent the field. Every
plugin offered one option, its own id, under "This plugin exposes a
single display mode"; the display resolved that id to the plugin's first
mode, so a multi-mode plugin could only be started, or pinned, there.

Add display_modes to each entry, read from the plugin catalog
(get_plugin_display_modes), the same declared list /display/modes and
on-demand/start use, keeping only strings. Single-mode plugins still get
one option and the same hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): enable a store install by its installed id, and not on reinstall

The store's Install button enabled the new plugin by the registry id it
installed. Weather, Music, Stocks and Leaderboard install under the id
their manifests declare (weather -> ledmatrix-weather); the plugin list,
the config section and /plugins/toggle know only that id, so the toggle
answered 404 "Plugin not found" and the plugin stayed disabled behind
"installed, but enabling it failed". The same button on an installed
plugin (Reinstall) enabled it too, switching a plugin the user had
turned off back on.

POST /plugins/install now names the installed plugin: plugin_id in the
direct answer and in the queued operation's result, read from the
installed manifest found the way the store's update and uninstall find
it (_find_plugin_path: id, aliases, plugin_path name), else the
requested id. The client reloads the list, then enables that id; from
an answer without it, the installed entry the store entry matches
(findInstalledStorePlugin, which isStorePluginInstalled now uses). A
reinstall, decided by the same match that labelled the button, reloads
the list and leaves the enabled state alone.

test/js/plugins_manager_sandbox.js runs the whole of
plugins_manager.js in a vm context against a fake DOM and API, for
suites that drive its real flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): wait for long store installs; on timeout reload, not fail

pollOperationStatus gave a queued install 60 polls, a second apart,
then reported "Install operation timed out" as an error and stopped.
The server allows the plugin's dependency install 300 s on its own
(install_requirements_file in store_install.py), after a download that
fetches the plugin a file at a time, so installs that went on to
succeed were reported as failed, never enabled, and left out of the
installed list until the page was reloaded.

Give installs INSTALL_POLL_MAX_ATTEMPTS (600, ten minutes). When even
that runs out, reload the installed list and the store badges and warn
that the install may still be running; nothing is enabled without the
operation's answer. Uninstall keeps the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): build the store's category filter from the store's plugins

The #plugin-category select listed seven fixed categories while the
registry uses about twenty (productivity, utility, transit, finance,
...), so roughly a third of the store could not be filtered to, and
"Financial" missed the plugin filed under "finance".

The template now ships only "All Categories"; syncStoreCategoryOptions,
run by applyStoreFiltersAndSort, adds one option per category the cached
store plugins have (case folded, as the filter compares), keeps the
current choice, and rebuilds only when the set changes or the partial
was swapped in afresh -- the way the Starlark section builds its own.

The test sandbox gains window.addEventListener (initPluginsPage needs
it) and quiets the script's "element not found" warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): one handler for the GitHub-URL Install button

#install-plugin-from-url had an inline onclick calling
window.handleGitHubPluginInstall, and attachInstallButtonHandler also
gave it a click listener that installs, so both ran on every click
(and on Enter, which clicks it). The inline handler threw a
ReferenceError -- it called isGithubUrl, which is local to the
plugin-manager IIFE, from outside it -- so only the listener's request
went out; correcting that scope alone would have sent every install
twice.

Remove the inline onclick and the window.handleGitHubPluginInstall it
called, which nothing else uses. The listener, which already sent the
only request, is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:18:54 -04:00

225 lines
9.9 KiB
JavaScript

/**
* Plugin installation and update management.
*
* Handles Check & Update All: which installed plugins it sends, retrying
* across a web-service restart, and summarising the results.
*/
const PluginInstallManager = {
/**
* Whether POST /plugins/update can update this installed-list entry.
*
* /plugins/installed also lists installed Starlark apps as virtual
* `starlark:<app_id>` entries (flagged `is_starlark_app`) so they can be
* seen and toggled with everything else. They are not plugin directories:
* the plugin updater has nothing to update for them, and there is no
* Starlark update route (reinstalling from the repository would reset the
* app's saved settings), so they are left out of update-all.
*
* @param {Object} plugin - Entry from /plugins/installed
* @returns {boolean}
*/
isUpdatablePlugin(plugin) {
if (!plugin || typeof plugin.id !== 'string' || !plugin.id) return false;
return !plugin.is_starlark_app && !plugin.id.startsWith('starlark:');
},
/**
* The entries update-all sends to POST /plugins/update, in list order.
*
* @param {Array} plugins - Entries from /plugins/installed
* @returns {Array}
*/
updatablePlugins(plugins) {
return (Array.isArray(plugins) ? plugins : []).filter(p => this.isUpdatablePlugin(p));
},
/**
* Backoff (ms) before re-sending an update whose request never got an
* answer. Covers a web-service restart (~3s on a Pi) with room to spare.
*/
NETWORK_RETRY_DELAYS_MS: [1000, 2000, 4000, 8000, 15000],
/**
* Update all plugins.
*
* @param {Function} onProgress - Optional callback(index, total, pluginId) for progress updates
* @param {Object} options - Optional { sleep(ms), retryDelaysMs } (tests inject these)
* @returns {Promise<Array>} Update results, one per plugin sent
*/
async updateAll(onProgress, options = {}) {
// window.installedPlugins is the live list: plugins_manager.js
// republishes it after every install, uninstall and refresh.
// PluginStateManager's copy is written only by the refresh at the end
// of a run, so preferring it sent a second run the first run's
// plugins -- an uninstalled one failed, a new one was skipped. It is
// the fallback for a page without the plugin manager.
const listed = Array.isArray(window.installedPlugins)
? window.installedPlugins
: ((window.PluginStateManager && window.PluginStateManager.installedPlugins) || []);
// Snapshot: the list can be replaced while this loop is awaiting.
const plugins = this.updatablePlugins(listed);
if (!plugins.length) {
return [];
}
const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
const retryDelays = options.retryDelaysMs || this.NETWORK_RETRY_DELAYS_MS;
const results = [];
for (let i = 0; i < plugins.length; i++) {
const plugin = plugins[i];
if (onProgress) onProgress(i + 1, plugins.length, plugin.id);
// Each plugin gets its own pass over the backoff schedule.
const pendingDelays = retryDelays.slice();
let lostAnswer = false;
for (;;) {
try {
const result = await window.PluginAPI.updatePlugin(plugin.id);
const entry = { pluginId: plugin.id, success: true, result };
if (lostAnswer) {
// An earlier attempt got no answer, so it may have
// updated the plugin before the connection dropped,
// and this answer then says up_to_date. restartRequest()
// reads these two.
entry.afterLostAnswer = true;
entry.enabled = plugin.enabled === true;
}
results.push(entry);
break;
} catch (error) {
// No HTTP answer at all (connection refused/reset, e.g. the
// web service restarting mid-run): the server never saw or
// never finished this plugin, so send it again once it is
// back rather than skipping it. An HTTP error response is
// the server's answer and is not retried.
if (error && error.error_code === 'NETWORK_ERROR' && pendingDelays.length > 0) {
lostAnswer = true;
await sleep(pendingDelays.shift());
continue;
}
results.push({ pluginId: plugin.id, success: false, error });
break;
}
}
}
// Reload plugin list once at the end. A failed refresh must not
// lose the results: they carry the restart flags. The plugin
// manager's load, forced past its caches, also redraws the installed
// grid and its Updates badge; PluginStateManager's only replaced
// window.installedPlugins, so the cards kept offering "Update to vX"
// for what had just been updated.
const pluginManager = window.pluginManager;
const refresh = (pluginManager && typeof pluginManager.loadInstalledPlugins === 'function')
? () => pluginManager.loadInstalledPlugins(true)
: (window.PluginStateManager ? () => window.PluginStateManager.loadInstalledPlugins() : null);
if (refresh) {
try {
await refresh();
} catch (error) {
console.warn('Could not refresh the installed plugin list after updating:', error);
}
}
return results;
},
/**
* Classify one POST /plugins/update answer.
*
* The route reports what actually happened in `data.update_status`
* (`updated`, `up_to_date`, `local_only`). A plugin the updater had
* nothing to do for -- e.g. a ZIP-installed monorepo plugin already at the
* registry version -- is still a success response, so it must not be
* counted as updated. Older servers only say so in the message.
*
* @param {Object} entry - One element of updateAll()'s results
* @returns {string} 'failed' | 'updated' | 'up_to_date' | 'local_only'
*/
updateOutcome(entry) {
if (!entry || !entry.success) return 'failed';
const result = entry.result || {};
const status = result.data && result.data.update_status;
if (status === 'up_to_date' || status === 'local_only' || status === 'updated') {
return status;
}
const message = typeof result.message === 'string' ? result.message : '';
if (message.includes('already up to date')) return 'up_to_date';
if (message.includes('managed locally')) return 'local_only';
return 'updated';
},
/**
* Summarise updateAll()'s results for the Check & Update All toast.
*
* @param {Array} results - updateAll()'s results
* @returns {{updated: number, upToDate: number, localOnly: number, failed: number, text: string, type: string}}
*/
summarizeUpdateResults(results) {
const counts = { updated: 0, up_to_date: 0, local_only: 0, failed: 0 };
for (const entry of (Array.isArray(results) ? results : [])) {
counts[this.updateOutcome(entry)]++;
}
const parts = [];
if (counts.updated > 0) parts.push(`${counts.updated} updated`);
if (counts.up_to_date > 0) parts.push(`${counts.up_to_date} already up to date`);
if (counts.local_only > 0) parts.push(`${counts.local_only} managed locally`);
if (counts.failed > 0) parts.push(`${counts.failed} failed`);
const type = counts.failed > 0 ? (counts.updated > 0 ? 'warning' : 'error') : 'success';
return {
updated: counts.updated,
upToDate: counts.up_to_date,
localOnly: counts.local_only,
failed: counts.failed,
text: parts.join(', '),
type
};
},
/**
* The first update answer that says the display needs a restart, or null.
*
* The display keeps running the code it loaded until it restarts, so an
* update of a plugin it runs answers `restart_required: true` (with the
* banner's wording in `restart_message`). One restart covers every
* plugin in the run, so one answer is enough; pass it to
* window.noteRestartRequired.
*
* Failing that, an enabled plugin whose first request got no answer and
* whose re-sent one says up_to_date may have been updated by the lost
* request, which nothing reported: that asks for a restart too, since a
* needless restart is cheaper than the display running old code.
*
* @param {Array} results - updateAll()'s results
* @returns {Object|null}
*/
restartRequest(results) {
const entries = Array.isArray(results) ? results : [];
for (const entry of entries) {
const body = entry && entry.success ? entry.result : null;
if (body && body.restart_required === true) return body;
}
for (const entry of entries) {
if (entry && entry.success && entry.afterLostAnswer && entry.enabled
&& this.updateOutcome(entry) === 'up_to_date') {
return {
restart_required: true,
restart_message: `Plugin ${entry.pluginId} may have been updated before the `
+ 'connection dropped — restart the display to be sure it runs the new version',
};
}
}
return null;
}
};
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = PluginInstallManager;
} else {
window.PluginInstallManager = PluginInstallManager;
window.updateAllPlugins = (onProgress) => PluginInstallManager.updateAll(onProgress);
}