Theseus: built-in extensions can answer paths under a name they ship with
Pithos needs its full-page app at pithos.sia/<user>/<drive>/<folder> instead of a loopback address. A new site-route capability lets a built-in add-on declare names in its manifest (siteRoutes) and register a handler for them; the bns:// handler asks it first for every path but the root, and a handler that returns nothing hands the request back to the name's own site. Community add-ons cannot use it, since answering for a name is impersonating it.
This commit is contained in:
parent
556a22b062
commit
bdc8f951c6
2 changed files with 60 additions and 3 deletions
|
|
@ -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/<user>/<drive>/…). 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. "<scheme>://<host>/<path>" 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(/^[\\/]/, ""));
|
||||
|
|
|
|||
14
main.js
14
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/<user>/<drive>/…). 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.)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue