Prevent stale Add-ons removal from deleting replacement

This commit is contained in:
MinhHaDuong 2026-09-22 11:21:48 +02:00
parent 8e7639d73d
commit 2fe028cb88
2 changed files with 108 additions and 0 deletions

View file

@ -460,6 +460,31 @@ function modify_omni {
replace_line 'let \{ BrowserAddonUI \} = windowRoot.window;' '' $file
replace_line 'await BrowserAddonUI.promptRemoveExtension' 'promptRemoveExtension' $file
file="chrome/toolkit/content/mozapps/extensions/components/addon-list.mjs"
# The Add-ons window can retain the old add-on wrapper after a replacement.
# Finalizing that wrapper's pending uninstall would remove the new copy by ID.
replace_line ' disconnectedCallback\(\) \{' ' async disconnectedCallback() {' $file
remove_line ' this.pendingUninstallAddons.clear\(\);' $file
replace_line ' for \(const addon of this.pendingUninstallAddons\) \{' \
' const pendingUninstalls = [...this.pendingUninstallAddons];
this.pendingUninstallAddons.clear();
for (const addon of pendingUninstalls) {' $file
replace_line ' if \(isPending\(addon, "uninstall"\)\) \{' \
' const current = await AddonManager.getAddonByID(addon.id);
if (this.isConnected) {
return;
}
if (current === addon && isPending(current, "uninstall")) {' $file
replace_line ' onInstalled\(addon\) \{' ' onInstalled(addon) {
if (!isPending(addon, "uninstall")) {
for (const pending of this.pendingUninstallAddons) {
if (pending.id === addon.id) {
this.pendingUninstallAddons.delete(pending);
this.removePendingUninstallBar(pending);
}
}
}' $file
# Customize empty-list message
replace_line 'createEmptyListMessage\(\) {' 'createEmptyListMessage() {
var p = document.createElement("p");

View file

@ -0,0 +1,83 @@
// Run against the addon-list.mjs extracted from the patched Firefox omni.ja:
// node app/scripts/tests/addon-list-pending-uninstall.test.mjs <path>
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import assert from 'node:assert/strict';
import { test } from 'node:test';
const sourcePath = process.argv.at(-1);
if (!sourcePath?.endsWith('addon-list.mjs')) {
throw new Error('Pass the patched addon-list.mjs path as the last argument');
}
const source = readFileSync(sourcePath, 'utf8')
.replace(/^import \{[\s\S]*?\} from "\.\.\/aboutaddons-utils\.mjs";\s*/mu, '')
.replace('export class AddonList', 'class AddonList')
.replace('customElements.define("addon-list", AddonList);', 'globalThis.AddonList = AddonList;');
function createList(currentById) {
const context = {
HTMLElement: class {},
ChromeUtils: {
importESModule: () => ({ AddonManager: {
getAddonByID: id => typeof currentById === 'function'
? currentById(id) : Promise.resolve(currentById.get(id)),
} }),
defineESModuleGetters: () => {},
},
isPending: addon => addon.pendingUninstall,
};
runInNewContext(source, context, { filename: sourcePath });
const list = new context.AddonList();
list.removeListener = () => {};
list.updateAddon = () => {};
list.removePendingUninstallBar = () => {};
return list;
}
test('a replacement clears the pending removal and survives list teardown', async () => {
let uninstalls = 0;
const old = { id: 'plugin@test', pendingUninstall: true, uninstall: () => uninstalls++ };
const replacement = { id: old.id, pendingUninstall: false };
const list = createList(new Map([[old.id, replacement]]));
list.pendingUninstallAddons.add(old);
list.onInstalled(replacement);
assert.equal(list.pendingUninstallAddons.size, 0);
await list.disconnectedCallback();
assert.equal(uninstalls, 0);
});
test('list teardown checks the live add-on even before the install event arrives', async () => {
let uninstalls = 0;
const old = { id: 'plugin@test', pendingUninstall: true, uninstall: () => uninstalls++ };
const replacement = { id: old.id, pendingUninstall: false };
const list = createList(new Map([[old.id, replacement]]));
list.pendingUninstallAddons.add(old);
await list.disconnectedCallback();
assert.equal(uninstalls, 0);
});
test('a genuinely pending removal still finalizes', async () => {
let uninstalls = 0;
const old = { id: 'plugin@test', pendingUninstall: true, uninstall: () => uninstalls++ };
const list = createList(new Map([[old.id, old]]));
list.pendingUninstallAddons.add(old);
await list.disconnectedCallback();
assert.equal(uninstalls, 1);
});
test('a reconnect during lookup leaves newly queued removals intact', async () => {
let resolveLookup;
let uninstalls = 0;
const old = { id: 'old@test', pendingUninstall: true, uninstall: () => uninstalls++ };
const fresh = { id: 'fresh@test', pendingUninstall: true };
const list = createList(() => new Promise(resolve => { resolveLookup = resolve; }));
list.pendingUninstallAddons.add(old);
const teardown = list.disconnectedCallback();
assert.equal(list.pendingUninstallAddons.size, 0);
list.isConnected = true;
list.pendingUninstallAddons.add(fresh);
resolveLookup(old);
await teardown;
assert.equal(uninstalls, 0);
assert.equal(list.pendingUninstallAddons.has(fresh), true);
});