theseus/docs/DESIGN-one-extension-directory.md
Local Dev 59593033a1 docs(theseus): one extension directory, not bundled versus community
Extensions read as two kinds today, and the division is not in the engine:
they already share one directory under the profile, one discovery pass and one
enable/disable switch. The split is imposed in four places, and this writes
down where and what each costs to remove.

The load-bearing one is that removal cannot stick. Seeding copies back any
pre-installed extension whose folder is missing, so deleting one is undone on
the next launch — which is why the UI marks them unremovable, and why there is
no uninstall action for anything at all. A tombstone list fixes both.

The urgent one is that the two publishing channels have one install path
between them. Everything installs through the community catalogue; the
operator channel has no index and no install route. So the Install buttons on
the shipped extensions' cards do not work, and moving an extension off the
community channel makes it uninstallable rather than better distributed.

Also records what not to flatten: one catalogue and one lifecycle, but the
signer stays visible, because an extension signed by the operator key and one
signed by a stranger's wallet get the same access once installed.
2026-09-21 22:46:11 +02:00

7.2 KiB

One extension directory

Extensions are currently presented as two kinds. They should be one kind with different provenance. This is what that costs and where the seams actually are.

The goal, stated plainly: one catalogue, one lifecycle. Every extension can be installed, removed, turned on and turned off by the same actions. Some arrive already installed. Who wrote it and who signed it is a fact about an extension — searchable, shown on its card — not a wall between two lists.

The good news: the runtime is already unified

None of the split is in the engine.

  • Every extension lives in one directory, <userData>/extensions/. A pre-installed one and a downloaded one land in the same place, side by side. seedBundledAddons() even says so: "the framework treats bundled and user add-ons identically, no special path".
  • One discovery pass activates them all — AddonHost.discoverAndActivate() scans that single directory.
  • Enable and disable are already uniform: settings.disabledAddons is a list of ids, and addons-set-enabled does not care where an extension came from.

So this is not a rewrite. It is removing four specific things that impose a division the engine never had.

Seam 1 — removal does not stick, so the UI hides it

seedBundledAddons() copies any bundled folder whose target is missing. Delete a pre-installed extension and it is back on the next launch. That single fact is why the UI marks them unremovable, and the comment on addons-list admits the motive: "Mark bundled add-ons so the extensions UI can render them differently… look built-in rather than removable extensions."

Worse, there is no uninstall at all — for anything. The only removal route is addons-reveal, opening the folder in the file manager, described in the source as "the primary way users edit / uninstall add-ons".

Fix. A tombstone list, settings.removedExtensions: string[].

  • Seeding skips any id on it.
  • Uninstall adds the id; installing again clears it.

Then removal means something for every extension, and a user who removes a pre-installed one does not have it resurrected by the next update.

Fix. An extensions-remove handler that deactivates the extension, moves its folder into the existing extensions-backups/<id>-<version>-<stamp>/ rather than deleting outright, tombstones the id and re-runs discovery. Its stored data in extensions-data/<id>.json survives by default so a reinstall keeps settings, with an explicit "also delete its data" option for people who mean it.

Seam 2 — two catalogues, and one of them cannot install

There are two publishing shapes today:

Where Signed by Indexed? Installable?
Operator channel bns/theseus/extensions/<id>/ operator Ed25519 key no index, per-id only no
Community bns/theseus/extensions/community/<id>/ publisher's BCH key, checked against the name's on-chain owner /api/ext/catalog yes

Every install route — the Settings button and the theseus://extensions/install/<id> deep link — goes through installCommunityById, which resolves the id against the community catalogue. main.js has no operator-channel install path at all.

Two consequences, both live right now:

  • The Install in Theseus buttons added to the Screenshot and PDF Editor cards do not work. Those ids are not in the community catalogue. It goes unnoticed only because both already ship in the build.
  • Moving an extension from the community channel to the operator channel makes it uninstallable unless it also ships in the build. This is exactly the trap the Word editor migration is walking into.

Fix. One catalogue endpoint listing everything, with the signing scheme as a field rather than as a separate world. /api/ext/catalog keeps its URL and merges both prefixes, each entry carrying:

{
  "id": "pdf-editor",
  "publisher": "Silent Mode",        // display name of the developer
  "signer": { "kind": "operator" },  // or { kind: "name", name: "theseus.x", owner: "bchtest:…" }
  "channel": "https://…/extensions/pdf-editor/updates.json",
  "latest": "0.2.0",
  "sha256": "…"
}

and one installExtension(id) in main.js that resolves from it and verifies with whichever key the entry declares. That is the smallest change that makes installation uniform, and it repairs the dead buttons on the way past.

Seam 3 — two lists in Settings

Settings has an Extensions section and a separate Plug-ins section, divided by manifest.category === "plugin" so that Aegis and friends "look built-in". That is the same division again, by a different field.

Fix. One list. Category becomes a filter value, not a page. Alongside it:

  • free-text search over name, description and developer
  • filter chips: All · Enabled · Disabled · Pre-installed · by developer
  • each card shows developer, version, where it updates from, and the same four actions for every row

Seam 4 — the website mirrors the split

site-theseus-x/extensions/index.html hardcodes a card per shipped extension and renders community ones from catalog.json into a separate section below. Adding an extension means hand-writing HTML.

Fix. Render every card from the one catalogue. The page already has the machinery — it fills version, SHA-256 and tarball URL live from each updates.json — it just needs one loop over one list instead of two.

The record an extension carries

AddonHost.snapshot() reports id, name, version, description, author, icon, capabilities, category, folder, enabled and error. main.js then bolts on bundled by looking for a same-named folder in resources/bundled-addons/.

Replace that flag and add the provenance the new UI wants to search on:

  • publisher — who signed the channel
  • origin"preinstalled" or "installed", a badge and nothing more
  • channel — its updates.json, so a card can say where updates come from
  • installedAt, updatedAt
  • removable — true for everything, once tombstones exist

bundled goes away. Nothing downstream should branch on where an extension came from.

One thing not to flatten

"Who the developer is" should not split the catalogue, and it should not be hidden either. An extension signed by the Silent Mode operator key and one signed by a stranger's wallet carry the same permissions once installed, and they are not equally safe. The consent dialog already gets this right — it names the publisher and says the extension "runs with the same access as any add-on". One list, one lifecycle, and the signer stated plainly on the card and in the prompt.

Order of work

Each step stands on its own and leaves the product working.

  1. Tombstones and extensions-remove. Removal becomes real for every extension. No UI change yet.
  2. One catalogue and one installExtension. Fixes the dead Install buttons and unblocks the Word editor migration.
  3. One Settings list with search and filters; Plug-ins becomes a filter.
  4. Site page renders from the catalogue instead of hardcoded cards.
  5. Drop bundled from addons-list and the last branches on it.

Step 2 is the one with a deadline attached: until it lands, nothing should be removed from the community catalogue, because that is the only route by which an extension can be installed.