feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan

Completes the three detection paths. The injected provider shipped in
0.8.8; these two needed host support, because nothing in the add-on API
could reach the active tab's content (captureTab is pixels, not DOM).

wiz:// links (main.js)
  A click on a wiz:// anchor is intercepted in will-navigate and in the
  window-open handler (target="_blank" lands there instead), and routed
  to the wallet with the offering page's origin attached, so the
  approval names the real site. The tab never navigates. This needs
  nothing from the dapp beyond rendering the URI as a link, so it works
  for third-party dapps that will never adopt a Silent Mode API.

scan-page capability (addons-host.js + main.js)
  New capability backing api.scanActiveTabForUris({scheme, limit}).
  Deliberately NOT a "read the page" API: the host runs the match and
  returns only the URIs found, so an add-on holding this still cannot
  see page text, markup or form values. It sits well below page-inject
  on the trust ladder — it learns that a page offers a wiz:// code and
  nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the
  result count is capped.

  The matcher also accepts WizardConnect's QR-alphanumeric spelling
  (WIZ://%3FP%3D…), which is frequently the only form present when a
  dapp renders its pairing code as a QR, and decodes it. Verified
  against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and
  QR-decoded alike.

  Regex sources are built host-side and passed as JSON rather than
  assembled inside the injected string — hand-escaping backslashes and
  quotes through two levels of literal was both wrong on the first
  attempt and unreviewable.

Aegis
  Declares scan-page, adds the wcScanPage handler and a "Scan page"
  button next to Connect. A scan fills the URI field and stops there
  rather than pairing outright: the user still chooses which wallet
  signs and still presses Connect, because a scan that silently paired
  would carry far more consequence than the button implies. Older hosts
  without the capability get a clear "update Theseus" message instead of
  a dead button.
This commit is contained in:
Local Dev 2026-09-23 00:16:49 +02:00
parent 64bc26ecf6
commit 8174fccba0
5 changed files with 834 additions and 664 deletions

View file

@ -47,8 +47,15 @@ const KNOWN_CAPABILITIES = new Set([
// one of its OWN HTML files as a full Theseus tab, with a // one of its OWN HTML files as a full Theseus tab, with a
// lean preload so the page can keep talking to the add-on // lean preload so the page can keep talking to the add-on
// via window.silentmode.invoke(). // via window.silentmode.invoke().
// scan-page: api.scanActiveTabForUris({scheme, limit}) — the HOST
// searches the active tab for URIs of one scheme and
// returns only those. The add-on never receives page text,
// markup or form values, so this sits well below
// page-inject or capture-tab on the trust ladder: it can
// learn that a page is offering e.g. a wiz:// pairing
// code, and nothing else about the page.
"vault-derive", "page-inject", "approval-modal", "capture-tab", "vault-derive", "page-inject", "approval-modal", "capture-tab",
"toolbar-menu", "open-tab", "toolbar-menu", "open-tab", "scan-page",
]); ]);
// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where // Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where
@ -160,7 +167,7 @@ function validateManifest(raw, folderName) {
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
// of the app queries via `getActive()` / `getInstalled()`. // of the app queries via `getActive()` / `getInstalled()`.
class AddonHost { class AddonHost {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, checkAndStageUpdates, restartApp }) { constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, scanTabForUris, checkAndStageUpdates, restartApp }) {
this.addonsDir = addonsDir; this.addonsDir = addonsDir;
this.dataDir = dataDir; this.dataDir = dataDir;
this.isDisabled = isDisabled || (() => false); this.isDisabled = isDisabled || (() => false);
@ -181,6 +188,7 @@ class AddonHost {
? arguments[0].vaultLifecycle : null; ? arguments[0].vaultLifecycle : null;
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null;
// Add-ons live outside the app's node_modules tree, so a bare require() // Add-ons live outside the app's node_modules tree, so a bare require()
// from their folder can't see Theseus's deps (ws, @noble/*, …). Main // from their folder can't see Theseus's deps (ws, @noble/*, …). Main
// hands us its own require so add-ons can share the bundled tree. // hands us its own require so add-ons can share the bundled tree.
@ -572,6 +580,28 @@ class AddonHost {
if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`); if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`);
return this._saveCapture(opts || {}, manifest.id); return this._saveCapture(opts || {}, manifest.id);
}, },
// scan-page: pull URIs of ONE scheme out of the active tab.
//
// Deliberately not a "read the page" API. The host does the matching
// and hands back only the URIs that matched, so an add-on with this
// capability still cannot see page text, form values or anything else
// it did not ask for. The scheme is fixed by the caller and validated
// here, and every call is expected to be user-initiated — nothing in
// the host polls a page on an add-on's behalf.
//
// opts.scheme e.g. "wiz" (required, [a-z][a-z0-9+.-]{0,19})
// opts.limit max URIs to return (default 20, hard cap 50)
// Resolves to `{ origin, uris: [string] }`.
scanActiveTabForUris: async (opts) => {
if (!manifest.capabilities.includes("scan-page")) {
throw new Error(`add-on "${manifest.id}" must declare the "scan-page" capability in addon.json`);
}
if (!this._scanTabForUris) throw new Error(`scanActiveTabForUris unavailable (host not wired)`);
const scheme = String(opts?.scheme || "").toLowerCase();
if (!/^[a-z][a-z0-9+.-]{0,19}$/.test(scheme)) throw new Error("invalid scheme");
const limit = Math.min(Math.max(Number(opts?.limit) || 20, 1), 50);
return this._scanTabForUris({ scheme, limit }, manifest.id);
},
}; };
} }

View file

@ -1,14 +1,14 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.8.9", "version": "0.9.0",
"category": "plugin", "category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E", "icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E",
"main": "index.js", "main": "index.js",
"updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json", "updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json",
"capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"], "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal", "scan-page"],
"absorbs": ["bchwallet", "siawallet"], "absorbs": ["bchwallet", "siawallet"],
"page-inject": { "page-inject": {
"preload": "wallet-inject.js", "preload": "wallet-inject.js",

View file

@ -1585,6 +1585,20 @@ function registerPanelMessages(api) {
return fullState(); return fullState();
}); });
// Scan the open dapp tab for a wiz:// pairing code, for dapps that render
// one but haven't adopted window.wizardconnect. Strictly user-initiated —
// it runs when someone presses "Scan page", never on a timer and never in
// the background. The host does the matching and returns only the URIs, so
// Aegis never receives page content.
api.onMessage("wcScanPage", async (_p, m) => {
fromPanel(m);
if (typeof api.scanActiveTabForUris !== "function") {
throw new Error("This Theseus build can't scan pages yet — update Theseus, or paste the wiz:// code manually.");
}
const { origin, uris } = await api.scanActiveTabForUris({ scheme: "wiz", limit: 10 });
return { origin: origin || null, uris: Array.isArray(uris) ? uris : [] };
});
// ---- WizardConnect from the page (0.8.8) -------------------------------- // ---- WizardConnect from the page (0.8.8) --------------------------------
// //
// WC was built for cross-device pairing: the dapp renders a QR, a phone // WC was built for cross-device pairing: the dapp renders a QR, a phone

View file

@ -1892,7 +1892,7 @@ function renderConnectPane(bchWallets) {
</div>`).join("") </div>`).join("")
: `<div class="hint" style="padding:6px 8px">No dapps paired yet.</div>`; : `<div class="hint" style="padding:6px 8px">No dapps paired yet.</div>`;
return `<div style="padding:6px"> return `<div style="padding:6px">
<div class="hint" style="margin-bottom:8px">Dapps that support Aegis directly can hand the pairing over with one click — no copying. Otherwise paste a <span class="mono">wiz://</span> URI from the dapp's Connect dialog. Aegis signs every request after your approval.</div> <div class="hint" style="margin-bottom:8px">Dapps that support Aegis hand the pairing over with one click. Otherwise open the dapp's Connect dialog and press <b>Scan page</b>, or paste its <span class="mono">wiz://</span> code below. Aegis signs every request after your approval.</div>
${blockedNote} ${blockedNote}
<div class="field"> <div class="field">
<div class="lbl">Sign with</div> <div class="lbl">Sign with</div>
@ -1901,8 +1901,9 @@ function renderConnectPane(bchWallets) {
<div class="field"> <div class="field">
<input type="text" id="pkConnectUri" spellcheck="false" placeholder="wiz://?p=…&s=…"> <input type="text" id="pkConnectUri" spellcheck="false" placeholder="wiz://?p=…&s=…">
</div> </div>
<div class="actions"> <div class="actions" style="gap:6px">
<button class="btn primary" id="pkConnectBtn">Connect</button> <button class="btn primary" id="pkConnectBtn">Connect</button>
<button class="btn" id="pkConnectScanBtn" title="Look for a wiz:// pairing code on the dapp tab you have open">Scan page</button>
</div> </div>
<div class="msg err" id="pkConnectMsg" hidden></div> <div class="msg err" id="pkConnectMsg" hidden></div>
<div class="lbl" style="margin-top:14px">Paired dapps</div> <div class="lbl" style="margin-top:14px">Paired dapps</div>
@ -1948,6 +1949,41 @@ function wireConnectPane() {
fillPicker(); fillPicker();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
}); });
const scanBtn = document.getElementById("pkConnectScanBtn");
if (scanBtn) scanBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const msg = document.getElementById("pkConnectMsg"); msg.hidden = true;
const field = document.getElementById("pkConnectUri");
const prev = scanBtn.textContent;
scanBtn.textContent = "Scanning…"; scanBtn.disabled = true;
try {
const res = await S.invoke("wcScanPage");
const uris = res?.uris || [];
if (!uris.length) {
msg.textContent = res?.origin
? `No wiz:// pairing code found on ${res.origin}. Open the dapp's Connect dialog first, then scan again.`
: "No pairing code found on the open tab.";
msg.hidden = false;
return;
}
// Fill the field rather than pairing outright: the user still picks
// which wallet signs, and still presses Connect. A scan that silently
// paired would be a click with a much larger consequence than the
// button implies.
field.value = uris[0];
msg.textContent = uris.length > 1
? `Found ${uris.length} codes on ${res.origin || "the page"} — filled the first. Press Connect to pair.`
: `Found a pairing code on ${res.origin || "the page"}. Press Connect to pair.`;
msg.classList.remove("err");
msg.hidden = false;
} catch (err) {
msg.textContent = cleanErr(err);
msg.classList.add("err");
msg.hidden = false;
} finally {
scanBtn.textContent = prev; scanBtn.disabled = false;
}
});
document.querySelectorAll("[data-wcpick]").forEach((b) => b.addEventListener("click", async () => { document.querySelectorAll("[data-wcpick]").forEach((b) => b.addEventListener("click", async () => {
const [walletId, connId] = b.dataset.wcpick.split("|"); const [walletId, connId] = b.dataset.wcpick.split("|");
try { state = await S.invoke("wcDisconnect", { walletId, connId }); fillPicker(); } try { state = await S.invoke("wcDisconnect", { walletId, connId }); fillPicker(); }

90
main.js
View file

@ -1860,6 +1860,59 @@ function initAddons() {
// and-stitch pass; kept for a later revision. // and-stitch pass; kept for a later revision.
// region — run the caller-supplied overlay source in the tab, wait // region — run the caller-supplied overlay source in the tab, wait
// for a rect (or null = cancel), then capturePage(rect). // for a rect (or null = cancel), then capturePage(rect).
// Back scan-page. The extraction runs IN the page and returns only the
// matched URIs — the add-on never sees the DOM. We look at anchor hrefs,
// visible text, and the handful of attributes a dapp realistically
// stashes a pairing code in (data-uri, value, title), then dedupe.
//
// WizardConnect also has a QR-alphanumeric form (WIZ://%3FP%3D…), which
// is often the ONLY thing in the DOM when a dapp renders a QR, so the
// matcher accepts the percent-encoded spelling too and decodes it.
scanTabForUris: async ({ scheme, limit }, addonId) => {
const t = activeTab();
if (!t) throw new Error("no active tab");
if (t.addonId || t.settings) throw new Error("open the dapp's tab first, then scan");
const wc = t.view.webContents;
const origin = pageOriginOf(wc.getURL());
// The regex SOURCES are built here and shipped as JSON. Assembling
// them inside the injected string instead means hand-escaping
// backslashes and quotes through two levels of literal, which is both
// easy to get wrong and unreviewable. JSON.stringify does it exactly.
// `scheme` is already validated against [a-z][a-z0-9+.-]* upstream, so
// it cannot carry regex metacharacters.
const plainSrc = `\\b${scheme}://[^\\s"'<>]{4,2048}`;
// Percent-encoded QR spelling: WIZ://%3FP%3D…
const qrSrc = `\\b${scheme}://(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9._~$+-])+`;
const arg = JSON.stringify({ plainSrc, qrSrc, limit });
const found = await wc.executeJavaScript(`(() => {
const { plainSrc, qrSrc, limit } = ${arg};
const out = new Set();
const plain = new RegExp(plainSrc, "gi");
const qr = new RegExp(qrSrc, "gi");
const push = (s) => {
if (!s || out.size >= limit) return;
let v = String(s).trim();
if (v.includes("%3F") || v.includes("%3f")) { try { v = decodeURIComponent(v); } catch {} }
if (v.length <= 2048) out.add(v);
};
const scan = (s) => {
if (!s) return;
for (const m of String(s).matchAll(plain)) push(m[0]);
for (const m of String(s).matchAll(qr)) push(m[0]);
};
for (const a of document.querySelectorAll("a[href]")) scan(a.getAttribute("href"));
for (const el of document.querySelectorAll("[data-uri],[data-wc-uri],[value],[title]")) {
scan(el.getAttribute("data-uri")); scan(el.getAttribute("data-wc-uri"));
scan(el.getAttribute("value")); scan(el.getAttribute("title"));
}
for (const el of document.querySelectorAll("input,textarea")) scan(el.value);
scan(document.body ? document.body.innerText : "");
return [...out].slice(0, limit);
})()`, true);
const uris = Array.isArray(found) ? found.filter((s) => typeof s === "string") : [];
console.log(`[addons] ${addonId} scanned ${origin || "tab"} for ${scheme}:// — ${uris.length} match(es)`);
return { origin, uris };
},
captureTab: async (opts, addonId) => { captureTab: async (opts, addonId) => {
// Prefer the currently-active tab, BUT if that's an add-on-owned page // Prefer the currently-active tab, BUT if that's an add-on-owned page
// (e.g. the screenshot editor is already up when the user re-picks a // (e.g. the screenshot editor is already up when the user re-picks a
@ -2692,6 +2745,13 @@ function createTab(initial, opts = {}) {
} }
return navigateTab(id, target + rest); return navigateTab(id, target + rest);
} }
// A wiz:// click never navigates — it hands the pairing URI to the
// wallet and leaves the dapp exactly where it is.
if (parsed.protocol === "wiz:") {
e.preventDefault();
routeWizUri(u, pageOriginOf(wc.getURL()), tab.id);
return;
}
if (parsed.protocol === "bns:") return; if (parsed.protocol === "bns:") return;
if (isBnsHost(parsed.hostname)) { if (isBnsHost(parsed.hostname)) {
// Only intercept cross-origin navigations. Same-origin (a form submit // Only intercept cross-origin navigations. Same-origin (a form submit
@ -2733,6 +2793,12 @@ function createTab(initial, opts = {}) {
}); });
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click. // Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc.setWindowOpenHandler(({ url, disposition }) => { wc.setWindowOpenHandler(({ url, disposition }) => {
// target="_blank" on a wiz:// link lands here rather than will-navigate.
// Route it to the wallet instead of opening a tab on an unloadable URL.
if (url && /^wiz:/i.test(url)) {
routeWizUri(url, pageOriginOf(wc.getURL()), tab.id);
return { action: "deny" };
}
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" }); if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" });
return { action: "deny" }; return { action: "deny" };
}); });
@ -3417,6 +3483,30 @@ function pageOriginOf(url) {
return u.protocol && u.host ? `${u.protocol}//${u.host}` : null; return u.protocol && u.host ? `${u.protocol}//${u.host}` : null;
} catch { return null; } } catch { return null; }
} }
// wiz:// — WizardConnect pairing links.
//
// WizardConnect is a cross-device protocol: a dapp renders its pairing URI
// as a QR for a phone wallet to scan. On the same device that means copying
// a wiz:// string out of one tab and pasting it into the wallet by hand.
// When a dapp renders the URI as a link instead, we can route the click
// straight to the wallet — no copying, and no change required on the dapp
// side beyond making it an anchor, so this works for third-party dapps that
// will never adopt a Silent Mode API.
//
// The wallet still shows its own approval before anything is paired; all
// this does is carry the URI across, tagged with the origin that offered it
// so the approval can name the real site.
function routeWizUri(uri, origin, tabId) {
if (!addonHost) return false;
const u = String(uri || "");
if (!/^wiz:/i.test(u) || u.length > 4096) return false;
if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false;
addonHost
.dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId })
.catch((err) => console.log("[wiz] pairing failed:", err?.message || err));
return true;
}
ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => { ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
const tab = tabForSender(e.sender); const tab = tabForSender(e.sender);
if (!tab || !addonHost) throw new Error("not a page"); if (!tab || !addonHost) throw new Error("not a page");