diff --git a/docs/DESIGN-one-extension-directory.md b/docs/DESIGN-one-extension-directory.md new file mode 100644 index 0000000..d772682 --- /dev/null +++ b/docs/DESIGN-one-extension-directory.md @@ -0,0 +1,161 @@ +# 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, `/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/--/` +rather than deleting outright, tombstones the id and re-runs discovery. Its +stored data in `extensions-data/.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//` | operator Ed25519 key | no index, per-id only | **no** | +| Community | `bns/theseus/extensions/community//` | 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/` 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.