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

161 lines
7.2 KiB
Markdown

# 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:
```jsonc
{
"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.