From fac6c8ebf681c1014b7cb0cae12519012399335c Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Wed, 2 Sep 2026 17:22:54 -0400 Subject: [PATCH] Name the responsible plugin when the items list fails to load A plugin that monkey-patches a method in the item list's load path -- say, Zotero.CollectionTreeRow.prototype.getItems() -- can throw and leave the pane showing only "Error loading items list", with nothing in the error report to identify it. Update the message to name the culprit. https://forums.zotero.org/discussion/133565/ --- .../content/zotero/collectionViewItemTree.jsx | 21 +++- chrome/content/zotero/xpcom/plugins.js | 101 ++++++++++++++++++ chrome/locale/en-US/zotero/zotero.ftl | 2 + 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx index c9f5906875..f16c5c614a 100644 --- a/chrome/content/zotero/collectionViewItemTree.jsx +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -50,6 +50,25 @@ const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.m const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$"); +/** + * Message for a failed items list load, naming the plugin responsible if the error came + * from plugin code + * + * @param {Error} e + * @return {Promise} + */ +async function getLoadErrorMessage(e) { + let plugin = await Zotero.Plugins.getPluginFromError(e); + if (!plugin) { + return Zotero.getString('pane.items.loadError'); + } + Zotero.warn(`Items list failed to load in a call from plugin ${plugin.name} (${plugin.id})`); + return Zotero.ftl.formatValueSync('items-list-load-error-plugin', { + // The message is rendered as HTML + plugin: Zotero.Utilities.htmlSpecialChars(plugin.name) + }); +} + // Minimal CollectionTreeRow-like object for callers that pass plain objects to // changeCollectionTreeRow()/setCollectionTreeRow() (e.g. advanced search). const STUB_COLLECTION_TREE_ROW = { @@ -628,7 +647,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { } await Zotero.Promise.delay(); this.runListeners('update', true, { - message: Zotero.getString('pane.items.loadError') + message: await getLoadErrorMessage(e) }); throw e; } diff --git a/chrome/content/zotero/xpcom/plugins.js b/chrome/content/zotero/xpcom/plugins.js index 8369a93c77..6fc8c989ec 100644 --- a/chrome/content/zotero/xpcom/plugins.js +++ b/chrome/content/zotero/xpcom/plugins.js @@ -334,6 +334,107 @@ Zotero.Plugins = new function () { }; + /** + * Identify the plugin, if any, whose code appears in an error's stack + * + * The topmost plugin frame wins, so an error thrown by Zotero code that a plugin called + * is attributed to the plugin that called it. + * + * @param {Error|String} error - An error or a stack string + * @return {Promise} - { id, name }, or false if no plugin frame was found + */ + this.getPluginFromError = async function (error) { + try { + var stack = typeof error == 'string' ? error : error?.stack; + if (!stack) { + return false; + } + var { addons } = await AddonManager.getActiveAddons(["extension"]); + if (!addons?.length) { + return false; + } + var roots = addons.map(addon => [addon, _archiveOrDirURI(addon.getResourceURI().spec)]); + for (let line of stack.split('\n')) { + let uri = _stackFrameURI(line); + if (!uri) { + continue; + } + uri = _resolveChromeOrResourceURI(uri); + if (!uri) { + continue; + } + uri = _archiveOrDirURI(uri); + for (let [addon, root] of roots) { + if (root && uri.startsWith(root)) { + return { id: addon.id, name: addon.name }; + } + } + } + return false; + } + catch (e) { + Zotero.logError(e); + return false; + } + }; + + + + /** + * Resolve a chrome:// or resource:// URI to the file or JAR URI it points at, since + * plugins can register packages and substitutions under both + * + * @param {String} uri + * @return {String} - The resolved URI, or an empty string if it can't be resolved + */ + function _resolveChromeOrResourceURI(uri) { + try { + if (uri.startsWith('chrome://')) { + return Cc["@mozilla.org/chrome/chrome-registry;1"] + .getService(Ci.nsIChromeRegistry) + .convertChromeURL(Services.io.newURI(uri)) + .spec; + } + if (uri.startsWith('resource://')) { + return Services.io.getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler) + .resolveURI(Services.io.newURI(uri)); + } + } + catch { + return ''; + } + return uri; + } + + + /** + * Reduce a URI to something comparable between plugin root URIs and stack frames: + * the containing archive for a jar: URI (which points inside an XPI), or the URI itself + * for a plugin installed unpacked + * + * @param {String} uri + * @return {String} + */ + function _archiveOrDirURI(uri) { + if (uri.startsWith('jar:')) { + let pos = uri.indexOf('!/'); + return pos == -1 ? uri.substring(4) : uri.substring(4, pos); + } + return uri; + } + + + /** + * @param {String} line - A line from an error stack, "name@uri:line:column" + * @return {String} - The URI, without the line and column + */ + function _stackFrameURI(line) { + // A function name can't contain "@", while a URI can (plugin IDs are email-like) + return line.slice(line.indexOf('@') + 1).replace(/:\d+(?::\d+)?$/, ''); + } + + this.getAllPluginIDs = async function () { let addons = await AddonManager.getAddonsByTypes(["extension"]); return addons.map(addon => addon.id); diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl index 060897a76b..169d32c0dc 100644 --- a/chrome/locale/en-US/zotero/zotero.ftl +++ b/chrome/locale/en-US/zotero/zotero.ftl @@ -206,6 +206,8 @@ collections-menu-clear-all-last-read = .label = Clear All Last Read Dates… recently-read-clear-all-confirm = All Last Read dates in this library will be erased. +items-list-load-error-plugin = Error loading items list. Disabling the “{ $plugin }” plugin and restarting { -app-name } may fix this. + # Item list section headers for a multiple-row collection-tree selection within one library items-section-collections-selected = { $count ->