diff --git a/addons-host.js b/addons-host.js index cde35c2c..300d5631 100644 --- a/addons-host.js +++ b/addons-host.js @@ -84,6 +84,15 @@ const KNOWN_CAPABILITIES = new Set([ // declare "sidebar-panel" typically follow up with // api.revealSidebar(panelId) to surface the result. "context-menu-item", + // site-route: manifest.siteRoutes = ["pithos.sia"] + api.registerSiteRoute + // ({ host, handle }) — the add-on answers requests for paths + // under a BCNR name it ships with (Pithos serves its app at + // pithos.sia///…). handle(request) gets the + // Fetch Request and returns a Response, or null for "not + // mine" (the name's own site then answers). The root page + // always stays the site's. Built-in add-ons only: answering + // for a name is impersonating it. + "site-route", ]); // Chrome-style match pattern → predicate. ":///" where @@ -205,6 +214,17 @@ function validateManifest(raw, folderName) { // one. Each entry is validated as an id itself and gates vault.derive by // (own id OR one of these) in makeApi below. const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : []; + // siteRoutes: BCNR names this add-on answers paths under (see site-route). + const siteRoutes = []; + if (capabilities.includes("site-route")) { + const list = Array.isArray(m.siteRoutes) ? m.siteRoutes : []; + for (const h of list) { + const host = String(h || "").trim().toLowerCase(); + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) throw new Error(`addon "${id}": siteRoutes entry "${h}" is not a host name`); + siteRoutes.push(host); + } + if (!siteRoutes.length) throw new Error(`addon "${id}": capability "site-route" needs a non-empty "siteRoutes" list`); + } for (const a of absorbs) { if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) { throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`); @@ -250,9 +270,9 @@ function validateManifest(raw, folderName) { let activationNote = ""; if (activation === "on-demand") { if (capabilities.includes("request-filter")) { activation = "startup"; activationNote = "request-filter add-ons start at launch"; } - else if (!panels.length && !toolbarMenu && !contextMenuItems.length && !pageInject) { activation = "startup"; activationNote = "declares nothing that could start it"; } + else if (!panels.length && !toolbarMenu && !contextMenuItems.length && !pageInject && !siteRoutes.length) { activation = "startup"; activationNote = "declares nothing that could start it"; } } - return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock, panels, activation, activationNote }; + return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock, panels, siteRoutes, activation, activationNote }; } // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest @@ -513,6 +533,19 @@ class AddonHost { return Promise.all([...this._dormant.keys()].map((id) => this.ensureActive(id, reason).catch(() => {}))); } isDormant(id) { return this._dormant.has(id); } + // The site route answering paths under `host`, starting its add-on if it + // waits for first use; null when no built-in add-on claims the name. + async siteRouteFor(host) { + const h = String(host || "").toLowerCase(); + for (const { manifest, dormant } of this._enabledEntries()) { + if (!(manifest.siteRoutes || []).includes(h)) continue; + if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) continue; + if (dormant) { try { await this.ensureActive(manifest.id, `${h} opened`); } catch { continue; } } + const route = this._active.get(manifest.id)?.siteRoutes.get(h); + if (route) return route; + } + return null; + } // Enabled = running or waiting for first use. isEnabled(id) { return this._active.has(id) || this._dormant.has(id); } @@ -544,7 +577,7 @@ class AddonHost { throw new Error(`main file must export an activate(api) function`); } // Declared panels are registered up front; activate() may still add more. - const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], ready: Promise.resolve() }; + const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() }; if (manifest.pageInject) { // Read the inject source once at activation. It's shipped to every // matching tab's preload verbatim, so a syntax error surfaces in the @@ -625,6 +658,16 @@ class AddonHost { // a short emoji/glyph. // `side: "left"` puts the panel in the quick-links strip on the left // edge instead of the right sidebar (hosts without left panels ignore it). + // See "site-route" above. Registered per activation, so a reload or a + // disabled add-on drops it with the rest of its state. + registerSiteRoute: ({ host, handle } = {}) => { + if (!(manifest.capabilities || []).includes("site-route")) throw new Error(`registerSiteRoute requires the "site-route" capability`); + if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) throw new Error("registerSiteRoute is reserved for built-in add-ons"); + const h = String(host || "").toLowerCase(); + if (!(manifest.siteRoutes || []).includes(h)) throw new Error(`registerSiteRoute: "${h}" is not in the manifest's siteRoutes`); + if (typeof handle !== "function") throw new Error("registerSiteRoute needs a handle(request) function"); + active.siteRoutes.set(h, { addonId: manifest.id, handle }); + }, registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side }) => { if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`); const abs = path.join(folder, String(page).replace(/^[\\/]/, "")); diff --git a/main.js b/main.js index 03570a43..e2aacaef 100644 --- a/main.js +++ b/main.js @@ -1973,6 +1973,20 @@ async function serveBns(request) { const rawPath = url.pathname || "/"; let reqPath; try { reqPath = decodeURIComponent(rawPath); } catch { reqPath = rawPath; } + // A built-in extension may answer paths under a name it ships with (Pithos + // serves its app at pithos.sia///…). The root page stays the + // name's own site, and whatever the extension does not claim falls through + // to the normal lookup below. + if (rawPath !== "/" && addonHost?.siteRouteFor) { + try { + const route = await addonHost.siteRouteFor(host); + if (route) { + const out = await route.handle(request); + if (out) return out; + } + } catch (e) { console.warn(`[site-route] ${host}${rawPath}: ${e?.message || e}`); } + } + // (bns://collision-choose/ is handled by the will-navigate listener attached // to each tab — it fires BEFORE the request reaches this protocol handler.)