Merge branch 'release/0.3.57'

This commit is contained in:
Local Dev 2026-09-27 11:49:18 +02:00
commit 130b7ecc5b
25 changed files with 13521 additions and 10032 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,54 +1,54 @@
// bcnr-preload.js — session-wide preload that installs `window.bcnr` on every
// page (regular tabs, popups, chrome/settings/etc). Reads only — no signing,
// no wallet unlock, no permission prompts. These four methods query the same
// resolver Theseus already runs for its address bar; nothing about the local
// user leaks, so no origin gate is needed for this surface.
//
// Sequencing: registered via `session.defaultSession.setPreloads([...])` in
// main.js at whenReady, which runs BEFORE per-WebContentsView preloads (home,
// settings, popover, etc.), so those preloads still install their own bridges
// on top of `window.bcnr`. See DESIGN-integrated-wallet.md §3 for the full
// API surface.
const { contextBridge, ipcRenderer } = require("electron");
// Every method returns a Promise; a name that fails to resolve or isn't
// registered comes back as `null` (not an error) so page code can treat
// "no such name" as data, not an exception. `getBcnrTlds` always returns
// an array — even the seed ["bch"] before the on-chain list has landed.
contextBridge.exposeInMainWorld("bcnr", {
resolveName: (name) => ipcRenderer.invoke("bcnr:resolveName", name),
isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name),
getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"),
getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name),
// Owner-signed DNS records (A/AAAA/MX/TXT/CNAME/NS) published beside the
// name's Sia content and verified by the gateway against the current NFT
// holder. `{ name, dns, seq, updatedAt, owner }`, or null when the name is
// unregistered or has published no manifest. Waits ≤ 3 s for a fetch.
dnsRecords: (name) => ipcRenderer.invoke("bcnr:dnsRecords", name),
// Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page.
// dApp devs use this to see how their subdomains bucket under one grant.
// Returns null for opaque origins (data:, blob:) which never hold grants.
getOrigin: () => ipcRenderer.invoke("bcnr:getOrigin"),
// One-click install of a community extension by catalog id (what
// theseus.x/extensions' Install button calls). The page names an id only;
// Theseus fetches the catalog itself, asks the user in a native dialog,
// verifies the publisher signature against the name's owner and installs.
// Resolves `{ ok, version, publisher }` or `{ ok:false, error }` (also
// "cancelled"). Pages can feature-detect it: absent on older builds.
installExtension: (id) => ipcRenderer.invoke("bcnr:installExtension", String(id || "")),
});
// Install-as-app relay. Theseus fires a synthetic `beforeinstallprompt` in
// pages whose manifest is installable (webapps.js); the page's prompt()
// dispatches a DOM event that this isolated world hears, asks main for the
// install dialog, and answers with another DOM event. Nothing is exposed
// on window; the page only ever sees Chrome's event shape. Top frame only.
try {
if (window.top === window) {
document.addEventListener("theseus:webapp-prompt", () => {
ipcRenderer.invoke("webapp-prompt").then((r) => {
document.dispatchEvent(new Event(r === "accepted" ? "theseus:webapp-accepted" : "theseus:webapp-dismissed"));
}).catch(() => { try { document.dispatchEvent(new Event("theseus:webapp-dismissed")); } catch {} });
});
}
} catch {}
// bcnr-preload.js — session-wide preload that installs `window.bcnr` on every
// page (regular tabs, popups, chrome/settings/etc). Reads only — no signing,
// no wallet unlock, no permission prompts. These four methods query the same
// resolver Theseus already runs for its address bar; nothing about the local
// user leaks, so no origin gate is needed for this surface.
//
// Sequencing: registered via `session.defaultSession.setPreloads([...])` in
// main.js at whenReady, which runs BEFORE per-WebContentsView preloads (home,
// settings, popover, etc.), so those preloads still install their own bridges
// on top of `window.bcnr`. See DESIGN-integrated-wallet.md §3 for the full
// API surface.
const { contextBridge, ipcRenderer } = require("electron");
// Every method returns a Promise; a name that fails to resolve or isn't
// registered comes back as `null` (not an error) so page code can treat
// "no such name" as data, not an exception. `getBcnrTlds` always returns
// an array — even the seed ["bch"] before the on-chain list has landed.
contextBridge.exposeInMainWorld("bcnr", {
resolveName: (name) => ipcRenderer.invoke("bcnr:resolveName", name),
isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name),
getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"),
getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name),
// Owner-signed DNS records (A/AAAA/MX/TXT/CNAME/NS) published beside the
// name's Sia content and verified by the gateway against the current NFT
// holder. `{ name, dns, seq, updatedAt, owner }`, or null when the name is
// unregistered or has published no manifest. Waits ≤ 3 s for a fetch.
dnsRecords: (name) => ipcRenderer.invoke("bcnr:dnsRecords", name),
// Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page.
// dApp devs use this to see how their subdomains bucket under one grant.
// Returns null for opaque origins (data:, blob:) which never hold grants.
getOrigin: () => ipcRenderer.invoke("bcnr:getOrigin"),
// One-click install of a community extension by catalog id (what
// theseus.x/extensions' Install button calls). The page names an id only;
// Theseus fetches the catalog itself, asks the user in a native dialog,
// verifies the publisher signature against the name's owner and installs.
// Resolves `{ ok, version, publisher }` or `{ ok:false, error }` (also
// "cancelled"). Pages can feature-detect it: absent on older builds.
installExtension: (id) => ipcRenderer.invoke("bcnr:installExtension", String(id || "")),
});
// Install-as-app relay. Theseus fires a synthetic `beforeinstallprompt` in
// pages whose manifest is installable (webapps.js); the page's prompt()
// dispatches a DOM event that this isolated world hears, asks main for the
// install dialog, and answers with another DOM event. Nothing is exposed
// on window; the page only ever sees Chrome's event shape. Top frame only.
try {
if (window.top === window) {
document.addEventListener("theseus:webapp-prompt", () => {
ipcRenderer.invoke("webapp-prompt").then((r) => {
document.dispatchEvent(new Event(r === "accepted" ? "theseus:webapp-accepted" : "theseus:webapp-dismissed"));
}).catch(() => { try { document.dispatchEvent(new Event("theseus:webapp-dismissed")); } catch {} });
});
}
} catch {}

View file

@ -1,14 +1,14 @@
{
"id": "aegis",
"name": "Aegis Wallet",
"version": "0.6.31",
"version": "0.9.0",
"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.",
"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",
"main": "index.js",
"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"],
"page-inject": {
"preload": "wallet-inject.js",

View file

@ -91,6 +91,7 @@ async function loadDeps(api) {
// the primary BCH adapter but a single fixed address per wallet.
const importedBchAdapter = require("./lib/chain-bch-imported.js")({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
HDKey, secp256k1, base58check, vaultImports: api.vault && api.vault.imports,
});
// Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum-
// based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC
@ -103,7 +104,7 @@ async function loadDeps(api) {
// chain-native private key). Used by the importWallet handler to compute
// the address client-side before wallet-imports.enc stores the material.
const derive = require("./lib/import-derive.js")({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256,
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory,
ecpairFactory: ECPairFactory, ecc, bip39, dgbCore,
});
@ -505,8 +506,43 @@ async function mountWallet(entry) {
...commonOpts,
cashaddr: entry.importedCashaddr || entry.importedAddress,
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
importId: entry.importId,
});
adapter.schedulePoll(20_000);
// Imported BCH wallets were never registered with WizardConnect —
// startForWallet only ran in the vault-derived branch. They showed
// up in the "Sign with" picker (they mount as ready) and then failed
// on pair with "no manager". Register them here too.
//
// WC derives a child-key tree, so this needs a seed: mnemonic/seed
// imports qualify, WIF single-key imports never can. registerWcEligible
// records which is which so the panel can say so up front instead of
// offering a pairing that cannot work.
if (c.wc) {
c.api.vault.imports.signer(entry.importId).then((blob) => {
if (ctx !== c) return;
if (!blob || blob.kind !== "seed" || !blob.seed) {
wcIneligible.set(entry.id, "This wallet was imported from a single private key. WizardConnect needs a seed phrase to derive the per-dapp keys it signs with.");
emitStateForWallet(entry.id);
return;
}
const seedHex = String(blob.seed).trim();
if (!/^[0-9a-f]+$/i.test(seedHex) || seedHex.length < 32) {
wcIneligible.set(entry.id, "Imported seed material is not in a form WizardConnect can derive from.");
emitStateForWallet(entry.id);
return;
}
const root = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16)));
return c.wc.startForWallet({
walletId: entry.id, label: entry.label,
root32: root, accountPath: entry.accountPath || "m/44'/145'/0'",
}).finally(() => { try { root.fill(0); } catch {} });
}).catch((e) => {
c.api.log(`[${entry.id}] wc start (imported):`, e?.message || e);
wcIneligible.set(entry.id, cleanWcErr(e));
emitStateForWallet(entry.id);
});
}
} else if (entry.chain === "btc" || entry.chain === "dgb") {
adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({
...commonOpts, chain: entry.chain, address: entry.importedAddress,
@ -518,6 +554,32 @@ async function mountWallet(entry) {
rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined),
});
adapter.schedulePoll(20_000);
} else if (entry.chain === "sc") {
// Sia's SiaWallet needs the 32-byte root at mount time — its key
// tree derives eagerly. Fetch the signer material from the vault
// (this branch runs only while the vault is unlocked; a locked
// vault would surface at import-time and gate the flow there).
// Falls back to a read-only stub if the fetch fails so a stray
// locked mount doesn't break panel rendering.
if (!c.api.vault?.imports || typeof c.api.vault.imports.signer !== "function") {
throw new Error("vault.imports.signer unavailable — cannot mount Sia import");
}
const signerBlob = await c.api.vault.imports.signer(entry.importId);
if (!signerBlob || signerBlob.kind !== "seed" || !signerBlob.seed) {
throw new Error("Sia signer material missing or malformed");
}
const seedHex = String(signerBlob.seed).trim();
if (!/^[0-9a-f]{64}$/i.test(seedHex)) throw new Error("Sia seed must be 32 bytes");
const rootBytes = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16)));
const walletdUrl = String(c.api.storage.get(`wallets/${entry.id}/walletdUrl`, "") || "");
adapter = new c.d.siaAdapter.SiaWallet(rootBytes, {
walletId: entry.id,
storage: c.api.storage,
log: (...a) => c.api.log(`[${entry.id}]`, ...a),
onChange: () => emitStateForWallet(entry.id),
walletdUrl,
});
if (walletdUrl && typeof adapter.startPolling === "function") adapter.startPolling();
} else {
throw new Error(`no imported adapter for chain "${entry.chain}"`);
}
@ -647,6 +709,7 @@ function unmountWallet(walletId) {
const rt = ctx.runtimes.get(walletId);
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} }
wcIneligible.delete(walletId);
ctx.runtimes.delete(walletId);
}
@ -721,6 +784,18 @@ function overallPhase() {
return "ready";
}
// Per-wallet reason a BCH wallet can't do WizardConnect even though it's
// mounted and ready — today that's single-key (WIF) imports, which have no
// seed to derive a per-dapp key tree from. Surfaced in walletSummary so the
// picker can grey them out instead of offering a pairing that must fail.
const wcIneligible = new Map();
function cleanWcErr(e) {
const m = e?.message || String(e);
return /locked|vault/i.test(m)
? "Unlock the password vault to use WizardConnect with this wallet."
: m;
}
function walletSummary(w) {
const meta = chainMeta(w.chain, w.network);
const rt = ctx.runtimes.get(w.id);
@ -737,8 +812,15 @@ function walletSummary(w) {
// stay null so the picker knows whether to render the mono path line.
accountPath: w.accountPath || snap?.accountPath || null,
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 },
// Per-wallet assets, so the coin drilldown can show what each ADDRESS
// holds instead of only the selected wallet's. `tokens` is the account-
// model shape (SPL / TRC20); `tokenBalances` is BCH CashTokens, keyed
// by category. Both stay null/empty for chains that have neither.
tokens: Array.isArray(snap?.tokens) ? snap.tokens : [],
tokenBalances: snap?.tokenBalances || null,
phase: rt?.phase || "locked",
error: rt?.error || null,
wcBlocked: w.chain === "bch" ? (wcIneligible.get(w.id) || null) : null,
};
}
@ -1024,6 +1106,31 @@ function registerPanelMessages(api) {
spec.wif = `aegis-privb58:${raw}`;
} else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); }
spec.cashaddr = address; // storage-key reuse — see BTC/DGB comment above
} else if (chain === "sc") {
// Siacoin. Uses a 32-byte root seed + u64 index (KeyFromSeed layout);
// no BIP44 path. Accepts a BIP39 12-word mnemonic (matches Sia
// Central Lite / walletd, PBKDF2 → first 32 bytes) or raw 32-byte
// seed hex. Address at index 0 is what we surface at import time;
// the mounted SiaWallet lets users advance through additional
// indices via the "Next unused address" affordance.
const net = network || "mainnet";
if (net !== "mainnet") throw new Error(`SC only supports mainnet (got ${net})`);
const index = Number(p && p.index != null ? p.index : 0);
if (!Number.isInteger(index) || index < 0) throw new Error("SC index must be a non-negative integer");
let seedHex;
if (p && p.mnemonic) {
const r = der.sc.fromMnemonic(String(p.mnemonic).trim(), index);
seedHex = r.seedHex; address = r.address;
} else if (p && p.seedHex) {
const r = der.sc.fromSeedHex(String(p.seedHex).trim(), index);
seedHex = r.seedHex; address = r.address;
} else { throw new Error("supply mnemonic or seedHex"); }
spec.kind = "seed";
spec.seed = seedHex;
// path field carries the Sia address index as an integer string,
// opaque to the vault. Mount reads it back as Number(spec.path).
spec.path = String(index);
spec.cashaddr = address;
} else {
throw new Error(`import not supported for chain "${chain}"`);
}
@ -1082,6 +1189,26 @@ function registerPanelMessages(api) {
});
api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); });
// Panel drilldown → refresh every wallet under a chain (optionally scoped
// to one subnetwork). Fires each adapter's refresh in parallel; individual
// failures set the adapter's own error field (surfaced back to the panel
// via emitStateForWallet) rather than aborting the batch. Returns the
// list of {id, ok, error} so the panel can flash a summary.
api.onMessage("refreshChain", async (p, m) => {
fromPanel(m);
const chain = String(p?.chain || "");
const network = p?.network ? String(p.network) : null;
if (!chain) throw new Error("chain is required");
const targets = walletEntries().filter((w) => w.chain === chain && (!network || w.network === network));
const out = [];
await Promise.all(targets.map(async (w) => {
const rt = ctx.runtimes.get(w.id);
if (!rt || !rt.adapter) { out.push({ id: w.id, ok: false, error: "adapter not mounted" }); return; }
try { await rt.adapter.refresh(true); out.push({ id: w.id, ok: true }); }
catch (e) { out.push({ id: w.id, ok: false, error: e?.message || String(e) }); }
}));
return { chain, network, results: out };
});
api.onMessage("nextAddress", (_p, m) => {
fromPanel(m);
const rt = requireSelected();
@ -1273,6 +1400,131 @@ function registerPanelMessages(api) {
return rt.adapter.signAndBroadcast(plan);
});
// ---- Balance consolidation ------------------------------------------------
// Batch send-max from every same-chain/same-network wallet (or a subset the
// user picked with checkboxes) into the currently-selected wallet. Runs in
// two phases:
// consolidatePreview — dry-run plan() per source; returns balance/fee/
// net/error so the panel renders a preview list
// with checkboxes without asking the user to
// approve anything yet.
// consolidateIntoSelected — signs + broadcasts one send per chosen source.
// The panel shows a single upfront confirmation
// (with the total to move and total fees); Theseus's
// per-tx approval overlay is skipped because the
// batch itself is the user's explicit intent.
api.onMessage("consolidatePreview", async (_p, m) => {
fromPanel(m);
const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected");
const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destSnap = destRt.adapter.snapshot();
const destAddr = destSnap.address;
if (!destAddr) throw new Error("destination wallet has no receive address");
const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain &&
w.network === destEntry.network &&
w.id !== destId,
);
const items = [];
for (const src of sources) {
const rt = ctx.runtimes.get(src.id);
const snap = rt?.adapter?.snapshot?.() || {};
const bal = snap.balance || {};
const totalUnits = typeof bal.confirmed === "string"
? (BigInt(bal.confirmed || "0") + BigInt(bal.unconfirmed || "0")).toString()
: String((bal.confirmed || 0) + (bal.unconfirmed || 0));
const base = {
walletId: src.id, label: src.label,
address: snap.address || null,
balance: totalUnits,
fee: null, net: null, error: null, eligible: false,
};
if (!rt?.adapter) { items.push({ ...base, error: "adapter not mounted" }); continue; }
if (typeof rt.adapter.plan !== "function") { items.push({ ...base, error: "adapter has no plan()" }); continue; }
// Dry-run send-max to the destination. plan() throws on empty /
// dust-only wallets — that's the "nothing to sweep" case and it
// reads as an error string per source in the preview.
try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
const fee = String(plan.fee ?? 0);
const net = String(plan.recipients?.[0]?.value ?? 0);
items.push({ ...base, fee, net, eligible: true });
} catch (e) {
items.push({ ...base, error: e?.message || String(e) });
}
}
const meta = chainMeta(destEntry.chain, destEntry.network) || null;
return {
destinationWalletId: destId,
destinationLabel: destEntry.label,
destinationAddress: destAddr,
chain: destEntry.chain,
network: destEntry.network,
ticker: meta?.ticker || "",
decimals: meta?.decimals || 8,
sources: items,
};
});
api.onMessage("consolidateIntoSelected", async (p, m) => {
fromPanel(m);
const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected");
const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destAddr = destRt.adapter.snapshot().address;
if (!destAddr) throw new Error("destination wallet has no receive address");
// sourceIds are the wallets the user CHECKED in the preview. Defaults
// to every eligible sibling if the panel omits the field (safety net,
// shouldn't happen in normal flow).
const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length
? new Set(p.sourceIds.map(String))
: null;
const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain &&
w.network === destEntry.network &&
w.id !== destId &&
(!requested || requested.has(w.id)),
);
const results = [];
for (const src of sources) {
const rt = ctx.runtimes.get(src.id);
if (!rt?.adapter || typeof rt.adapter.plan !== "function") {
results.push({ walletId: src.id, label: src.label, ok: false, error: "adapter not mounted" });
continue;
}
try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
const r = await rt.adapter.signAndBroadcast(plan);
results.push({
walletId: src.id, label: src.label, ok: true,
txid: r?.txid || null,
sent: String(plan.recipients?.[0]?.value ?? 0),
fee: String(plan.fee ?? 0),
});
} catch (e) {
results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) });
}
}
// Force a refresh on the destination so its balance jumps once the txs
// reach the network's mempool. Silent-fail — panel will pick up state
// on the next state emit anyway.
try { if (typeof destRt.adapter.refresh === "function") destRt.adapter.refresh(false); } catch {}
return {
destinationWalletId: destId,
destinationAddress: destAddr,
chain: destEntry.chain,
network: destEntry.network,
results,
};
});
api.onMessage("recovery", async (p, m) => {
fromPanel(m);
const id = String(p && p.id || selectedWalletId());
@ -1333,6 +1585,80 @@ function registerPanelMessages(api) {
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) --------------------------------
//
// WC was built for cross-device pairing: the dapp renders a QR, a phone
// scans it. Same-device that means copying a wiz:// string out of one
// tab and into the wallet by hand. These two handlers back the
// window.wizardconnect bridge so a dapp can hand Aegis the URI it has
// already generated, and the user just approves.
// Which BCH wallets can actually pair right now. Used by the page bridge
// AND by isReady() so a dapp can decide between "hand it to Aegis" and
// "render the QR" before it commits to either.
function wcPairableWallets() {
if (!ctx.wc) return [];
return walletEntries()
.filter((w) => w.chain === "bch")
.map((w) => ({ entry: w, rt: ctx.runtimes.get(w.id) }))
.filter(({ entry, rt }) => rt && rt.phase === "ready" && !wcIneligible.has(entry.id))
.map(({ entry }) => entry);
}
api.onMessage("wcPageReady", async (_p, m) => {
fromPage(m);
// Deliberately coarse: a page learns only whether pairing is possible,
// never how many wallets exist or what they are.
return { available: !!ctx.wc, pairable: wcPairableWallets().length > 0 };
});
api.onMessage("wcConnectFromPage", async (p, m) => {
const origin = fromPage(m);
if (!ctx.wc) throw new Error("WizardConnect is still starting up — try again in a moment");
const uri = String(p && p.uri || "").trim();
// Validate before showing any UI so a malformed or hostile value can't
// put a confusing approval in front of the user.
if (!/^wiz:\/\//i.test(uri)) throw new Error("not a WizardConnect URI");
if (uri.length > 4096) throw new Error("WizardConnect URI is implausibly long");
const candidates = wcPairableWallets();
if (!candidates.length) {
throw new Error("No Bitcoin Cash wallet is ready to pair. Unlock the Aegis vault (or add a BCH wallet) and try again.");
}
// Prefer the selected wallet when it qualifies, so the approval matches
// whatever the user currently sees in the panel.
const selId = selectedWalletId();
const chosen = candidates.find((w) => w.id === selId) || candidates[0];
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Pair this site with your wallet?",
origin,
body: "The site will be able to ask Aegis to sign Bitcoin Cash transactions over WizardConnect. Every signature still needs your approval — pairing on its own moves no funds.",
rows: [
{ label: "Wallet", value: `${chosen.label}` },
{ label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true },
],
actions: [{ id: "allow", label: "Pair", primary: true }],
});
if (!String(pick || "").startsWith("allow")) throw new Error("pairing declined");
await ctx.wc.connectUri(chosen.id, uri);
return { paired: true, wallet: chosen.label };
});
});
// Reorder wallets by an explicit ID list. Silently drops IDs that are
// not in the current wallet set (removed since the panel last read);
// appends any wallets missing from `order` to the end of the list so a
@ -1700,6 +2026,52 @@ function registerPageMessages(api) {
return rt.adapter.signMessage(message);
});
});
// BCH message verification (BIP-137). Panel-only path: given a message,
// a base64 signature, and an address, return { valid, address,
// recoveredHash }. No approval modal (nothing spendable happens), no
// wallet lookup — pure crypto against the given address.
// Panel → BCMR resolver. Batched: pass an array of category hex strings,
// get back { <categoryHex>: {name, symbol, iconUri, decimals, source} }
// for every one that resolved. Missed categories map to null. This
// triggers a background fetch for anything not in the disk cache, so
// the second call for the same set returns instantly.
api.onMessage("tokenMetadata", async (p, m) => {
fromPanel(m);
const cats = Array.isArray(p?.categories) ? p.categories.map(String).filter((c) => /^[0-9a-f]{64}$/i.test(c)) : [];
if (!cats.length) return {};
const entries = await ctx.bcmr.lookupMany(cats);
const out = {};
for (const [cat, entry] of Object.entries(entries)) {
out[cat] = ctx.bcmr.metadataOf(entry);
}
return out;
});
// Read the configured BCMR registry list (defaults + any user additions).
api.onMessage("bcmrRegistries", (_p, m) => {
fromPanel(m);
return { registries: ctx.bcmr.registryList() };
});
// Overwrite the registry list. Empty array restores defaults on next read.
api.onMessage("setBcmrRegistries", (p, m) => {
fromPanel(m);
ctx.bcmr.setRegistries(Array.isArray(p?.registries) ? p.registries : []);
return { registries: ctx.bcmr.registryList() };
});
api.onMessage("verifyMessage", (p, m) => {
fromPanel(m);
const message = String(p?.message != null ? p.message : "");
const signature = String(p?.signature || "");
const address = String(p?.address || "");
if (!signature || !address) throw new Error("signature and address are required");
try {
return ctx.d.keysLib.verifyMessage(message, signature, address, {
cashaddr: ctx.d.cashaddr, secp256k1: ctx.d.secp256k1,
});
} catch (e) {
return { valid: false, error: e?.message || String(e) };
}
});
// ---- Tron bridge (tronWeb / tronLink) -----------------------------------
api.onMessage("trx.requestAccounts", async (_p, m) => {
@ -2251,6 +2623,13 @@ module.exports = {
log: (...a) => api.log("prices", ...a),
onChange: () => emitState(),
}),
// BCMR (CashTokens metadata registry) — resolves category hex to
// { name, symbol, iconUri, decimals }. Storage-scoped so per-user
// caches don't stomp each other; disk-cached with 6h TTL.
bcmr: require("./lib/bcmr.js")({
storage: api.storage,
log: (...a) => api.log("bcmr", ...a),
}),
wc: null, // WizardConnect manager, initialised when deps load
};
migrateLegacyStorage(api);

View file

@ -0,0 +1,146 @@
// BCMR (Bitcoin Cash Metadata Registry) fetcher + cache. Resolves a
// CashTokens category hex to human-readable metadata: name, description,
// symbol, decimals, icon URL, and per-NFT metadata when the registry
// carries it.
//
// Registries are plain JSON documents (Bitauth "Bitcoin Cash Metadata
// Registries v2" schema). We support two ways to reach a registry today:
//
// 1. HTTPS URL configured per-user in Settings ("registry endpoints").
// The registry publishes a compact JSON with keyed identities;
// lookup by category is O(1).
// 2. Static bundled fallback (registries/) for a handful of well-known
// tokens (Cauldron, Fex.cash, TapSwap, ParyonUSD). Ships in the
// addon so brand-new users see names on the first launch even
// before they configure a live registry.
//
// Cache is on-disk via api.storage under "bcmr/<categoryHex>" =
// { snapshot, fetchedAt, source }. A metadata refresh runs at most once
// per REFRESH_MIN_MS per category to keep the panel snappy on repaint.
// No signature verification yet (BCMR v2 spec allows authchain-anchored
// signing; adding that is a follow-up once we support arbitrary chain
// script parsing).
const REFRESH_MIN_MS = 6 * 60 * 60 * 1000; // 6 hours
// Well-known registries seeded on first run so a fresh wallet doesn't need
// any configuration to see names for the top BCH tokens. Users can add /
// remove entries in Settings.
const DEFAULT_REGISTRIES = [
{ id: "cashonize", label: "Cashonize registry", url: "https://raw.githubusercontent.com/cashonize/registry/main/bcmr.json" },
{ id: "salemkode", label: "SalemKode registry", url: "https://bcmr.salemkode.com/registry.json" },
];
module.exports = function makeBcmr({ storage, log = () => {} }) {
function registryList() {
const custom = storage.get("bcmr/registries", null);
if (Array.isArray(custom) && custom.length) return custom;
return DEFAULT_REGISTRIES.slice();
}
function setRegistries(list) {
const clean = Array.isArray(list) ? list.filter((r) => r && typeof r.url === "string" && /^https?:\/\//i.test(r.url)) : [];
storage.set("bcmr/registries", clean);
}
// Registry lookup: index-into-registry by category. BCMR v2 stores
// identities keyed by category id (hex). Each identity has a history
// array; the newest history[0] entry is the current snapshot.
function pickIdentity(regJson, categoryHex) {
const identities = regJson?.identities || {};
const identity = identities[categoryHex];
if (!identity) return null;
// History is a { <timestamp>: snapshot } map. Newest wins by ISO
// string sort — the schema recommends ISO 8601 timestamps and both
// registries above emit them, so lexicographic sort matches temporal
// sort for anything after 1000 AD.
const entries = Object.entries(identity);
if (!entries.length) return null;
entries.sort((a, b) => (b[0] > a[0] ? 1 : -1));
const [, snap] = entries[0];
return snap;
}
async function fetchRegistry(url) {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`bcmr: HTTP ${r.status} from ${url}`);
return r.json();
}
// Attempt every configured registry in parallel; first identity found
// wins. When two registries carry a category, we prefer the one earlier
// in the list (user-configured order = priority).
async function lookup(categoryHex) {
const registries = registryList();
if (!registries.length) return null;
// Try cache first.
const cached = storage.get(`bcmr/${categoryHex}`, null);
if (cached && Date.now() - (cached.fetchedAt || 0) < REFRESH_MIN_MS) return cached;
const attempts = await Promise.all(registries.map(async (reg) => {
try {
const json = await fetchRegistry(reg.url);
const identity = pickIdentity(json, categoryHex);
return identity ? { identity, source: reg.label || reg.id, url: reg.url } : null;
} catch (e) {
log(`bcmr: registry "${reg.label || reg.url}" failed:`, e?.message || e);
return null;
}
}));
const hit = attempts.find((a) => a);
if (!hit) {
// Negative cache with a short TTL so a missing category doesn't
// hammer every registry on every wallet refresh.
const miss = { snapshot: null, fetchedAt: Date.now(), source: null, url: null };
storage.set(`bcmr/${categoryHex}`, miss);
return miss;
}
const entry = {
snapshot: hit.identity,
fetchedAt: Date.now(),
source: hit.source,
url: hit.url,
};
storage.set(`bcmr/${categoryHex}`, entry);
return entry;
}
// Batch lookup — returns { <categoryHex>: cacheEntry }. Reuses individual
// lookup() which handles per-category caching + negative caching.
async function lookupMany(categoryHexes) {
const out = {};
await Promise.all(categoryHexes.map(async (cat) => {
try { out[cat] = await lookup(cat); }
catch (e) { out[cat] = { snapshot: null, error: e?.message || String(e) }; }
}));
return out;
}
// Read-only cached lookup — never hits network. Used for the panel's
// synchronous render path so tokens draw immediately with whatever's
// in the cache; the async lookup() runs in the background afterwards.
function cached(categoryHex) {
return storage.get(`bcmr/${categoryHex}`, null);
}
// Compact metadata slice the panel wants: { name, symbol, description,
// decimals, iconUri }. Handles both the top-level identity fields and
// the token subobject (BCMR v2 puts token-specific data there).
function metadataOf(entry) {
if (!entry || !entry.snapshot) return null;
const s = entry.snapshot;
const t = s.token || {};
return {
name: s.name || t.name || null,
symbol: s.token?.symbol || s.symbol || null,
description: s.description || null,
decimals: Number.isFinite(Number(t.decimals)) ? Number(t.decimals) : 0,
// Icon URIs live under s.uris.icon per schema; older files use s.icon.
iconUri: s.uris?.icon || s.icon || null,
source: entry.source || null,
};
}
return { lookup, lookupMany, cached, metadataOf, registryList, setRegistries, DEFAULT_REGISTRIES };
};

View file

@ -31,7 +31,10 @@ function convertBits(data, from, to, pad) {
return out;
}
// type: 0 = P2PKH, 1 = P2SH. hash: 20 bytes (the only size we emit).
// type: 0 = P2PKH, 1 = P2SH, 2 = P2PKH+TOKEN, 3 = P2SH+TOKEN (CashTokens
// address types, CHIP-2022-02). hash: 20 bytes (the only size we emit).
// The type is a 5-bit value stored in the upper nibble of the version byte,
// so any type up to 15 encodes cleanly; every caller here uses 0-3.
function encode(prefix, type, hash) {
if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported");
const versionByte = (type << 3) | 0; // size bits 000 = 160
@ -41,6 +44,14 @@ function encode(prefix, type, hash) {
for (let i = 0; i < 8; i++) checksum.push(Number((mod >> BigInt(5 * (7 - i))) & 0x1fn));
return prefix + ":" + [...payload, ...checksum].map((v) => CHARSET[v]).join("");
}
// Whether a decoded address type carries the CashTokens "token-aware" flag.
// Callers use it to warn on token sends to non-token-aware addresses (a
// consensus rule — sending tokens to type 0/1 is a burn).
function isTokenAware(type) { return type === 2 || type === 3; }
// Fold a token-aware address type down to its bare equivalent so the
// UTXO / locking-script path can stay one-shape (P2PKH vs P2SH). The
// token payload is written via the 0xef prefix, not the address type.
function bareType(type) { return type & 0x01; }
// Accepts "prefix:payload" or a bare payload (assumes defaultPrefix).
function decode(address, defaultPrefix = "bitcoincash") {
@ -88,15 +99,30 @@ function decodeLegacy(address, sha256) {
return { prefix: "bitcoincash", type, hash: body.slice(1) };
}
// Anything a user might paste -> { type, hash, cashaddr }. Rejects other
// prefixes so a chipnet address can never be paid on mainnet by accident.
// Anything a user might paste -> { type, hash, cashaddr, tokenAware }.
// Rejects wrong prefixes so a chipnet address can never be paid on
// mainnet by accident. Type 2/3 (CashTokens-aware) is folded to type
// 0/1 for the locking-script side; the token-aware flag flows through
// so callers building token outputs can refuse to burn tokens on a
// non-aware recipient.
function parseAny(input, sha256, prefix = "bitcoincash") {
const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:");
if (!s) throw new Error("empty address");
const r = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/.test(s) ? decodeLegacy(s, sha256) : decode(s, prefix);
if (r.prefix !== prefix) throw new Error(`address is for "${r.prefix}", expected "${prefix}"`);
if (r.type !== 0 && r.type !== 1) throw new Error("unsupported address type");
return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) };
if (r.type < 0 || r.type > 3) throw new Error(`unsupported address type ${r.type}`);
const tokenAware = isTokenAware(r.type);
const bare = bareType(r.type);
return {
type: bare, hash: r.hash, tokenAware,
// Round-trip through encode() so the returned cashaddr is
// canonical-cased and normalised, even if the input was a legacy
// Base58 (1…/3…) form. Emits type 0/1 by default; callers that
// want the token-aware form for display can re-encode with type
// 2/3 explicitly.
cashaddr: encode(prefix, bare, r.hash),
cashaddrTokenAware: encode(prefix, bare | 0x02, r.hash),
};
}
module.exports = { encode, decode, decodeLegacy, parseAny };
module.exports = { encode, decode, decodeLegacy, parseAny, isTokenAware, bareType };

View file

@ -0,0 +1,206 @@
// CashTokens (CHIP-2022-02) primitives — decode + encode the prefix byte
// that wraps a token-carrying scriptPubKey. Pure functions, no wallet or
// network state. Used by:
// - wallet.js → classify UTXOs (bare BCH vs fungible vs NFT vs both)
// - tx.js → build token outputs
// - panel.js → render token balances / send flows
//
// Prefix layout (CashTokens spec):
//
// 0xef — PREFIX_TOKEN marker
// category_id (32 bytes) — genesis txid of the token, LE-serialised
// token_bitfield (1 byte) — see BITS below
// [commitment_length (varint)] — present iff HAS_COMMITMENT_LENGTH
// [commitment (bytes)] — length equal to commitment_length
// [amount (varint)] — present iff HAS_AMOUNT (fungible token)
// <locking script bytes> — the "real" P2PKH / P2SH / … script
//
// Bitfield layout (spec §"Token Prefix Encoding"):
// Upper nibble = STRUCTURE bits (which fields are present):
// 0x10 HAS_AMOUNT — fungible token amount is encoded
// 0x20 HAS_NFT — NFT is present (commitment optional)
// 0x40 HAS_COMMITMENT_LENGTH — commitment_length is present
// 0x80 reserved (must be 0)
// Lower nibble = NFT CAPABILITY (meaningful only when HAS_NFT):
// 0x00 none / immutable
// 0x01 mutable
// 0x02 minting
// 0x03-0x0F reserved (must be 0)
const PREFIX_TOKEN = 0xef;
// Bit masks (STRUCTURE).
const HAS_AMOUNT = 0x10;
const HAS_NFT = 0x20;
const HAS_COMMITMENT_LENGTH = 0x40;
const STRUCTURE_RESERVED = 0x80;
// NFT capabilities. Values are read from bitfield & 0x0f.
const CAP_NONE = 0x00; // immutable NFT (or "no NFT" when HAS_NFT bit is off)
const CAP_MUTABLE = 0x01;
const CAP_MINTING = 0x02;
const CAP_LABEL = { 0: "immutable", 1: "mutable", 2: "minting" };
// Varint (compact size) encode/decode used for commitment length AND for
// the fungible-token amount. Amounts up to 9,223,372,036,854,775,807 sats
// (2^63-1) are legal; larger values are consensus-invalid, so we cap and
// throw on encode.
function readVarint(bytes, pos) {
if (pos >= bytes.length) throw new Error("cashtokens: truncated varint");
const first = bytes[pos];
if (first < 0xfd) return { value: BigInt(first), next: pos + 1 };
if (first === 0xfd) {
if (pos + 3 > bytes.length) throw new Error("cashtokens: truncated 0xfd varint");
return { value: BigInt(bytes[pos + 1] | (bytes[pos + 2] << 8)), next: pos + 3 };
}
if (first === 0xfe) {
if (pos + 5 > bytes.length) throw new Error("cashtokens: truncated 0xfe varint");
return {
value: BigInt(bytes[pos + 1]) | (BigInt(bytes[pos + 2]) << 8n)
| (BigInt(bytes[pos + 3]) << 16n) | (BigInt(bytes[pos + 4]) << 24n),
next: pos + 5,
};
}
// 0xff = 8-byte little-endian u64
if (pos + 9 > bytes.length) throw new Error("cashtokens: truncated 0xff varint");
let v = 0n;
for (let i = 0; i < 8; i++) v |= BigInt(bytes[pos + 1 + i]) << BigInt(8 * i);
return { value: v, next: pos + 9 };
}
function writeVarint(v) {
const n = typeof v === "bigint" ? v : BigInt(v);
if (n < 0n) throw new Error("cashtokens: negative varint");
if (n < 0xfdn) return Uint8Array.from([Number(n)]);
if (n <= 0xffffn) return Uint8Array.from([0xfd, Number(n & 0xffn), Number((n >> 8n) & 0xffn)]);
if (n <= 0xffffffffn) {
return Uint8Array.from([
0xfe, Number(n & 0xffn), Number((n >> 8n) & 0xffn),
Number((n >> 16n) & 0xffn), Number((n >> 24n) & 0xffn),
]);
}
if (n > (1n << 63n) - 1n) throw new Error("cashtokens: amount exceeds i64 max");
const out = new Uint8Array(9);
out[0] = 0xff;
let x = n;
for (let i = 1; i <= 8; i++) { out[i] = Number(x & 0xffn); x >>= 8n; }
return out;
}
// Split a scriptPubKey into { token, lockingScript, rawPrefix }. token is
// null when the script is NOT prefixed by 0xef. lockingScript is the
// tokenless portion — every downstream check (P2PKH, P2SH, OP_RETURN,
// electrum scripthash) works off THAT, so token-carrying and bare UTXOs
// stay comparable through the existing wallet code.
function decodePrefixedScript(script) {
const bytes = script instanceof Uint8Array ? script : Uint8Array.from(script);
if (!bytes.length || bytes[0] !== PREFIX_TOKEN) {
return { token: null, lockingScript: bytes, rawPrefix: null };
}
if (bytes.length < 1 + 32 + 1) throw new Error("cashtokens: prefix truncated at category");
let pos = 1;
const category = bytes.slice(pos, pos + 32); pos += 32;
const bitfield = bytes[pos]; pos += 1;
if (bitfield & STRUCTURE_RESERVED) throw new Error("cashtokens: reserved structure bit set");
const hasAmount = !!(bitfield & HAS_AMOUNT);
const hasNft = !!(bitfield & HAS_NFT);
const hasCommitLen = !!(bitfield & HAS_COMMITMENT_LENGTH);
const capability = bitfield & 0x0f;
// Structure invariants (spec):
// - Commitment-length present implies HAS_NFT (a commitment without an
// NFT is meaningless) AND commitment_length ≥ 1.
// - Capability lower nibble is only meaningful when HAS_NFT is set.
// - At least one of HAS_AMOUNT / HAS_NFT must be set, otherwise the
// prefix carries no useful info and should be rejected.
if (!hasAmount && !hasNft) throw new Error("cashtokens: prefix carries neither amount nor nft");
if (hasCommitLen && !hasNft) throw new Error("cashtokens: commitment without NFT");
if (!hasNft && capability !== 0) throw new Error("cashtokens: capability bits set on fungible-only prefix");
if (hasNft && capability > 2) throw new Error(`cashtokens: unknown NFT capability ${capability}`);
let commitment = null;
if (hasCommitLen) {
const clen = readVarint(bytes, pos); pos = clen.next;
if (clen.value === 0n) throw new Error("cashtokens: zero-length commitment");
if (clen.value > 40n) throw new Error(`cashtokens: commitment exceeds 40 bytes (${clen.value})`);
const length = Number(clen.value);
if (pos + length > bytes.length) throw new Error("cashtokens: commitment truncated");
commitment = bytes.slice(pos, pos + length); pos += length;
}
let amount = 0n;
if (hasAmount) {
const av = readVarint(bytes, pos); pos = av.next;
if (av.value === 0n) throw new Error("cashtokens: zero fungible amount");
if (av.value > (1n << 63n) - 1n) throw new Error("cashtokens: fungible amount overflow");
amount = av.value;
}
const lockingScript = bytes.slice(pos);
const rawPrefix = bytes.slice(0, pos);
return {
token: {
category, categoryHex: toHex(category),
amount, hasAmount, hasNft, capability, capabilityLabel: hasNft ? CAP_LABEL[capability] : null,
commitment, commitmentHex: commitment ? toHex(commitment) : null,
},
lockingScript, rawPrefix,
};
}
// Encode a { category, amount, nft: { commitment, capability } } spec into
// the prefix bytes ready to be prepended to a locking script. Absent fields
// mean "not present" — e.g. { amount: 100n } → fungible only.
function encodePrefix({ category, amount = 0n, nft = null }) {
const cat = category instanceof Uint8Array
? category
: Uint8Array.from(String(category).match(/../g).map((h) => parseInt(h, 16)));
if (cat.length !== 32) throw new Error("cashtokens: category must be 32 bytes");
const amt = typeof amount === "bigint" ? amount : BigInt(amount || 0);
if (amt < 0n) throw new Error("cashtokens: negative amount");
const hasAmount = amt > 0n;
const hasNft = !!nft;
const commitment = hasNft && nft.commitment
? (nft.commitment instanceof Uint8Array
? nft.commitment
: Uint8Array.from(String(nft.commitment).match(/../g).map((h) => parseInt(h, 16))))
: null;
const hasCommitLen = hasNft && commitment && commitment.length > 0;
if (commitment && commitment.length > 40) throw new Error("cashtokens: commitment > 40 bytes");
const capability = hasNft ? (Number(nft.capability) || 0) : 0;
if (capability > 2) throw new Error(`cashtokens: bad NFT capability ${capability}`);
if (!hasAmount && !hasNft) throw new Error("cashtokens: must have amount or NFT");
let bitfield = 0;
if (hasAmount) bitfield |= HAS_AMOUNT;
if (hasNft) bitfield |= HAS_NFT;
if (hasCommitLen) bitfield |= HAS_COMMITMENT_LENGTH;
bitfield |= capability & 0x0f;
const parts = [Uint8Array.from([PREFIX_TOKEN]), cat, Uint8Array.from([bitfield])];
if (hasCommitLen) { parts.push(writeVarint(commitment.length)); parts.push(commitment); }
if (hasAmount) parts.push(writeVarint(amt));
return concat(...parts);
}
// Prepend a token prefix to an existing locking script (P2PKH etc).
function wrapScript(prefix, lockingScript) {
return concat(prefix, lockingScript);
}
// Concise helper: given a JSON-serialisable descriptor and a P2PKH pubkey
// hash, produce the full token-carrying scriptPubKey ready for an output.
function tokenP2PKHScript({ category, amount = 0n, nft = null }, h160) {
const prefix = encodePrefix({ category, amount, nft });
const locking = Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
return wrapScript(prefix, locking);
}
// Utilities (kept private to this file to avoid coupling with tx.js).
function concat(...parts) {
const n = parts.reduce((a, p) => a + p.length, 0);
const out = new Uint8Array(n); let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function toHex(b) { return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); }
module.exports = {
PREFIX_TOKEN, HAS_AMOUNT, HAS_NFT, HAS_COMMITMENT_LENGTH,
CAP_NONE, CAP_MUTABLE, CAP_MINTING, CAP_LABEL,
decodePrefixedScript, encodePrefix, wrapScript, tokenP2PKHScript,
readVarint, writeVarint,
};

View file

@ -1,17 +1,21 @@
// Imported BCH wallet — single-address, key material lives in Theseus's
// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's
// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but
// does NOT go through vault.derive + HKDF: derivation is direct from the
// seed+path or WIF that the user imported.
// public shape (snapshot, refresh, plan, signAndBroadcast, signMessage,
// dispose) but does NOT go through vault.derive + HKDF: derivation is
// direct from the seed+path or WIF that the user imported.
//
// M.1a scope: read-only (balance + history over Electrum). planSend/send
// throw with a clear message until M.1b lands the sign path.
// 0.6.36+: spend path enabled. plan() builds a P2PKH tx off the wallet's
// single scripthash UTXO set; signAndBroadcast() pulls the signer material
// from api.vault.imports.signer(importId), decodes the WIF or derives the
// mnemonic/path into a 32-byte priv key, and signs every input in RAM.
// The private key never lands in adapter state — signAndBroadcast fetches
// it fresh per broadcast and drops it before returning.
module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) {
module.exports = function makeImportedBchAdapter({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
HDKey, secp256k1, base58check, vaultImports,
}) {
// Same electrum scripthash convention chain-bch uses: sha256(script), byte-
// reversed, hex. P2PKH-only for imports today — that's what every entry in
// Deviant's keystore is.
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
@ -19,9 +23,9 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
const IMPORTED_BCH_NETWORKS = {
mainnet: {
id: "mainnet", label: "Mainnet", prefix: "bitcoincash",
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80,
explorerTx: "https://bchexplorer.cash/tx/",
explorerAddr: "https://bchexplorer.cash/address/",
defaultServers: [
"wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004",
@ -30,7 +34,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
],
},
chipnet: {
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest",
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef,
explorerTx: "https://chipnet.imaginary.cash/tx/",
explorerAddr: "https://chipnet.imaginary.cash/address/",
defaultServers: [
@ -41,9 +45,6 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
},
};
// Decode a cashaddr → 20-byte hash160 payload. We stored cashaddr at import
// time and use it here to compute the scripthash for Electrum without ever
// asking main for the signer material — that only happens at sign time.
function h160OfCashaddr(addr) {
const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, "");
const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean);
@ -51,12 +52,56 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
return hash;
}
// Decode a WIF-encoded private key. Accepts both mainnet (0x80) and
// testnet (0xef) version bytes and both compressed and uncompressed
// forms; returns { priv (32 bytes), compressed (bool) }.
function decodeWif(wif, versionByte) {
const bytes = base58check.decodeCheck(String(wif).trim());
if (!(bytes[0] === versionByte || bytes[0] === 0x80 || bytes[0] === 0xef)) {
throw new Error(`unexpected WIF version 0x${bytes[0].toString(16)}`);
}
const compressed = bytes.length === 34 && bytes[33] === 0x01;
const priv = bytes.slice(1, 33);
if (priv.length !== 32) throw new Error("WIF payload is not 32 bytes");
return { priv, compressed };
}
// Derive a P2PKH signer (32-byte priv + 33-byte compressed pubkey) from
// whatever vault.imports.signer returned. Two shapes today:
// { kind: "seed", seed: hex, path: "m/…" } — BIP32 derivation
// { kind: "wif", wif: base58check } — direct decode
// Anything else (or a missing signer) throws with a clear message so
// the panel can surface it rather than the broadcast returning garbage.
function signerToKey(signerBlob, net) {
if (!signerBlob) throw new Error("no signer material for this wallet");
if (signerBlob.kind === "seed") {
const seed = signerBlob.seed;
if (!/^[0-9a-f]+$/i.test(seed)) throw new Error("seed material must be hex");
const seedBytes = Uint8Array.from(seed.match(/../g).map((x) => parseInt(x, 16)));
const node = HDKey.fromMasterSeed(seedBytes).derive(signerBlob.path || "m");
return { priv: node.privateKey, pub: node.publicKey };
}
if (signerBlob.kind === "wif") {
const { priv } = decodeWif(signerBlob.wif, net.wifVersion);
const pub = secp256k1.getPublicKey(priv, true);
return { priv, pub };
}
throw new Error(`unknown signer kind: ${signerBlob.kind}`);
}
class ImportedBchWallet {
constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) {
constructor({
walletId, storage, log = () => {}, onChange = () => {},
network = "mainnet", cashaddr: address, servers, importId,
} = {}) {
const net = IMPORTED_BCH_NETWORKS[network];
if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`);
if (!address) throw new Error("chain-bch-imported: cashaddr required");
this.walletId = walletId;
// importId is the vault-side id used to fetch the signer at
// sign-time. Optional here so a mount without spend capability
// still works (read-only surface unaffected).
this._importId = importId || null;
this.chain = "bch";
this.network = net.id;
this._net = net;
@ -73,6 +118,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
this._state = {
balance: { confirmed: 0, unconfirmed: 0 },
history: [],
utxos: [],
height: 0,
scanning: false,
error: null,
@ -107,6 +153,10 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
server: this._client.url || null,
servers: this._servers,
imported: true,
// 0.6.36+: imported wallets can spend when the vault signer is
// reachable (i.e. Theseus is unlocked). canSpend reflects that so
// the panel can enable the Send tab without probing.
canSpend: !!this._importId,
explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet,
@ -116,11 +166,15 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
async refresh(full) {
this._state.scanning = true; this._emit();
try {
// Balance for this single scripthash.
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
// Always pull UTXOs so spend / send-max work off fresh state.
const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]);
this._state.utxos = (Array.isArray(utxos) ? utxos : []).map((u) => ({
txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0),
}));
if (full) {
const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]);
const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0,
}));
@ -135,11 +189,105 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
}
nextAddress() { return { address: this._address, index: 0 }; }
current() { return { address: this._address, index: 0, branch: 0, path: null, h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex }; }
current() {
return {
address: this._address, index: 0, branch: 0, path: null,
h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex,
};
}
plan() { throw new Error("Imported wallets are read-only in this build. Spending support ships in the next Aegis update."); }
signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); }
signMessage() { throw new Error("Imported wallets are read-only in this build."); }
// 0.6.36 spend path. Builds an unsigned P2PKH plan against the wallet's
// own UTXO set. Signing happens in signAndBroadcast, which fetches the
// key material from Theseus's vault at broadcast time — nothing key-
// bearing lives in the plan itself, so a plan can round-trip through
// the approval overlay without leaking secrets.
plan(spec) {
if (!this._state.utxos.length) throw new Error("wallet has no unspent outputs to spend from");
const targets = Array.isArray(spec?.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec?.to, value: spec?.amount ?? spec?.value }];
const outs = targets.map((t) => {
const a = cashaddr.parseAny(t.to, sha256, this._net.prefix);
const script = a.type === 0
? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac])
: Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
});
if (spec?.memo) outs.push({ value: 0, script: tx.memoScript(String(spec.memo)), data: true, memo: String(spec.memo) });
const rate = Math.min(10, Math.max(1, Number(spec?.feeRate) || 1));
// Imported wallets have exactly one address, so change goes back to
// itself — no need to derive a fresh change entry from an HD tree.
const changeScript = this._script;
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
const sel = tx.select(spendable, outs, rate, changeScript, { sendMax: !!spec?.sendMax });
const nonData = sel.outputs.filter((o) => !o.data);
const total = sel.outputs.reduce((a, o) => a + o.value, 0);
return {
...sel,
feeRate: rate,
recipients: nonData
.filter((_, i) => outs[i] && !outs[i].data)
.map((o, i) => ({ to: outs[i].to, value: o.value })),
memo: spec?.memo || null,
total,
};
}
async signAndBroadcast(plan) {
if (!this._importId) throw new Error("this wallet has no signer registered");
if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable");
const signerBlob = await vaultImports.signer(this._importId);
let key;
try {
key = signerToKey(signerBlob, this._net);
// Belt-and-braces: the signer must match the wallet's own address.
// Catches vault-side corruption and any accidental cross-mount.
const derivedH160 = hash160(key.pub);
const same = derivedH160.length === this._h160.length && derivedH160.every((b, i) => b === this._h160[i]);
if (!same) throw new Error("signer material does not match this wallet's address");
const inputs = plan.inputs.map((u) => ({ ...u, script: this._script }));
const t = { inputs, outputs: plan.outputs };
const signed = tx.sign(t, (inp, _i, digest) => ({
sig: secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "der" }),
publicKey: key.pub,
}));
const txid = await this._client.call("blockchain.transaction.broadcast", [signed.hex]);
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
this.log("broadcast", txid);
setTimeout(() => this.refresh(false).catch(() => {}), 1500);
return { txid, hex: signed.hex, fee: plan.fee };
} finally {
// Wipe the private material before returning. Not perfect (JS can
// still relocate the underlying buffer during GC) but it minimises
// the window in which the raw key sits in this frame.
if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} }
}
}
// BIP-137 message signing from the imported key. Same MAGIC / double-
// sha256 payload as chain-bch.js so the resulting sig verifies through
// Electron Cash and every other BCH tool.
async signMessage(message) {
if (!this._importId) throw new Error("this wallet has no signer registered");
if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable");
const signerBlob = await vaultImports.signer(this._importId);
let key;
try {
key = signerToKey(signerBlob, this._net);
const enc = new TextEncoder();
const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); };
const MAGIC = "Bitcoin Signed Message:\n";
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
const digest = sha256(sha256(payload));
const sig = secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "recovered" });
const out = new Uint8Array(65);
out[0] = 27 + sig[0] + 4;
out.set(sig.subarray(1), 1);
return { address: this._address, signature: Buffer.from(out).toString("base64") };
} finally {
if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} }
}
}
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; }

View file

@ -12,8 +12,8 @@ const BCH_NETWORKS = {
label: "Mainnet",
prefix: "bitcoincash",
defaultAccountPath: "m/44'/145'/0'",
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
explorerTx: "https://bchexplorer.cash/tx/",
explorerAddr: "https://bchexplorer.cash/address/",
defaultServers: [
"wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004",
@ -105,6 +105,9 @@ module.exports = function makeBchAdapter({
addressIndex: w.addressIndex,
addressPath: w.addressPath,
balance: w.balance,
// CashTokens balances (0.7.0+). Categories → { fungible: str,
// nfts: [...], utxoCount }. Empty object when no tokens held.
tokenBalances: w.tokenBalances || {},
height: w.height,
history: w.history,
scanning: w.scanning,
@ -126,7 +129,8 @@ module.exports = function makeBchAdapter({
const targets = Array.isArray(spec.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec.to, value: spec.amount ?? spec.value }];
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax });
const memo = typeof spec.memo === "string" ? spec.memo : "";
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax, memo });
}
async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); }
// 65-byte BIP-137 recoverable signature — the format Electron Cash and

View file

@ -1,137 +1,389 @@
// Generic single-address read-only imported adapter for account-model
// chains. One config-driven runtime handles ETH-family, Tron, and Solana
// balance polling — every chain differs only in the RPC verb and the
// JSON path to the balance number.
//
// The adapter mirrors the public shape every Aegis chain runtime exposes
// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet
// stays chain-agnostic. planSend/send throw a "read-only" error until
// M.1b delivers the sign path per chain.
module.exports = function makeGenericImportedAdapter() {
const CHAIN_CFGS = {
eth: {
ticker: "ETH", decimals: 18,
networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" },
sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" },
},
// JSON-RPC eth_getBalance → hex-string wei.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) });
const j = await r.json();
const hex = String(j?.result || "0x0").replace(/^0x/, "");
return BigInt("0x" + hex).toString();
},
},
trx: {
ticker: "TRX", decimals: 6,
networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" },
nile: { id: "nile", label: "Nile testnet", rpc: "https://api.nileex.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" },
},
// Tron HTTP API returns account.balance in SUN (10^-6 TRX).
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ address, visible: true }) });
const j = await r.json();
return String(j?.balance || 0);
},
},
sol: {
ticker: "SOL", decimals: 9,
networks: {
mainnet: { id: "mainnet", label: "Mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/" },
devnet: { id: "devnet", label: "Devnet", rpc: "https://api.devnet.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/", explorerSuffix: "?cluster=devnet", testnet: true, faucet: "https://faucet.solana.com/" },
},
// Solana JSON-RPC getBalance returns lamports as a number.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) });
const j = await r.json();
return String(j?.result?.value || 0);
},
},
};
class GenericImportedWallet {
constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) {
const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`);
const net = cfg.networks[network]; if (!net) throw new Error(`chain-generic-imported: ${chain} has no network ${network}`);
if (!address) throw new Error("address required");
this.chain = chain;
this.network = network;
this._cfg = cfg;
this._net = { ...net, rpc: rpcUrl || net.rpc };
this.log = log;
this.onChange = onChange;
this._address = address;
this._state = {
balance: { confirmed: "0", unconfirmed: "0" },
history: [],
scanning: false,
error: null,
};
this._pollTimer = null;
}
setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ }
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: this.chain, network: this.network,
ticker: this._cfg.ticker, decimals: this._cfg.decimals,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
history: this._state.history,
scanning: this._state.scanning,
error: this._state.error,
server: this._net.rpc,
rpcUrl: this._net.rpc,
imported: true,
explorerAddr: this._net.explorerAddr,
explorerTx: this._net.explorerTx,
explorerSuffix: this._net.explorerSuffix || "",
faucet: this._net.faucet || null,
};
}
async refresh() {
this._state.scanning = true; this._emit();
try {
const confirmed = await this._cfg.fetchBalance({ rpc: this._net.rpc, address: this._address });
this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" };
this._state.error = null;
} catch (e) {
this._state.error = e?.message || String(e);
} finally {
this._state.scanning = false;
this._emit();
}
}
nextAddress() { return { address: this._address, index: 0 }; }
current() { return { address: this._address, index: 0, branch: 0, path: null }; }
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); }
signAndBroadcast() { throw new Error("read-only"); }
signMessage() { throw new Error("read-only"); }
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
dispose() { clearTimeout(this._pollTimer); }
}
return { GenericImportedWallet, CHAIN_CFGS };
};
// Generic single-address read-only imported adapter for account-model
// chains. One config-driven runtime handles ETH-family, Tron, and Solana
// balance polling — every chain differs only in the RPC verb and the
// JSON path to the balance number.
//
// The adapter mirrors the public shape every Aegis chain runtime exposes
// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet
// stays chain-agnostic. planSend/send throw a "read-only" error until
// M.1b delivers the sign path per chain.
module.exports = function makeGenericImportedAdapter() {
// base58check T… -> 41-prefixed hex, for comparing against the raw
// owner_address/to_address fields the /v1 tx feed returns.
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function tronAddrToHex(b58) {
try {
let n = 0n;
for (const ch of String(b58)) {
const i = B58.indexOf(ch);
if (i < 0) return "";
n = n * 58n + BigInt(i);
}
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
// 25 bytes = 21 payload + 4 checksum; drop the checksum.
return hex.padStart(50, "0").slice(0, 42);
} catch { return ""; }
}
// Airdrop spam is the norm on public addresses — a real test address came
// back with 855 ERC-20s and 3078 SPL mints. Rendering all of those in a
// sidebar is useless, so every fetchTokens caps its list. Sorting puts
// named/known tokens first, so the cap drops spam before it drops
// anything the user recognises.
const TOKEN_CAP = 50;
// Token names are attacker-controlled. Scam mints ship symbols that are
// blank, pure whitespace, zero-width characters, or carry bidi overrides
// to make one string render as another. Strip the invisible classes, cap
// the length, and return "" when nothing legible survives so the caller
// can mark the token unknown instead of rendering an empty-looking row
// that borrows trust from the ones above it.
// Ranges are listed numerically rather than as a regex character class on
// purpose: a literal class would need these very characters in the source,
// where they are invisible to a reviewer and easy for an editor or a patch
// tool to mangle.
const INVISIBLE_RANGES = [
[0x0000, 0x001f], [0x007f, 0x009f], // C0 / C1 controls
[0x200b, 0x200f], // zero-width space..RTL mark
[0x202a, 0x202e], // bidi embedding / override
[0x2060, 0x206f], // word joiner, invisible operators
[0xfeff, 0xfeff], // BOM / zero-width no-break space
];
function cleanTokenText(s) {
let out = "";
for (const ch of String(s == null ? "" : s)) {
const cp = ch.codePointAt(0);
if (INVISIBLE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) continue;
out += ch;
}
return out.replace(/\s+/g, " ").trim().slice(0, 32);
}
const CHAIN_CFGS = {
eth: {
ticker: "ETH", decimals: 18,
networks: {
// `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints
// above serve balances but have no history or token concept at all —
// that's why imported ETH wallets showed a balance and nothing else.
// Etherscan V2 would need an API key; Blockscout does not.
// publicnode, not llamarpc: llamarpc was answering 525 with an HTML
// error page, which surfaced as a JSON parse error and a 0 balance.
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://ethereum-rpc.publicnode.com", indexer: "https://eth.blockscout.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" },
sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", indexer: "https://eth-sepolia.blockscout.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" },
},
// JSON-RPC eth_getBalance → hex-string wei.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) });
const j = await r.json();
const hex = String(j?.result || "0x0").replace(/^0x/, "");
return BigInt("0x" + hex).toString();
},
async fetchHistory({ address, net }) {
if (!net?.indexer) return null;
const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/transactions`, { headers: { accept: "application/json" } });
if (!r.ok) throw new Error(`Blockscout history HTTP ${r.status}`);
const j = await r.json();
const items = Array.isArray(j?.items) ? j.items : [];
const me = String(address).toLowerCase();
return items.slice(0, 25).map((t) => {
const from = String(t.from?.hash || "").toLowerCase();
const wei = BigInt(String(t.value || "0"));
const outgoing = from === me;
// A mempool tx comes back as {result:"pending", status:null,
// timestamp:null}. Reading that as `status !== "ok" → failed`
// showed pending sends as failures, which is the one thing a
// wallet must never get wrong.
const pending = t.result === "pending" || t.status == null;
return {
txid: t.hash,
time: Math.floor(new Date(t.timestamp || 0).getTime() / 1000) || 0,
confirmations: Number(t.confirmations) || 0,
status: pending ? "pending" : (t.status === "ok" ? "confirmed" : "failed"),
// Keep wei exact — 18 decimals overflows a JS number.
delta: (outgoing ? -wei : wei).toString(),
kind: t.method || "Transfer",
};
}).filter((t) => t.txid);
},
async fetchTokens({ address, net }) {
if (!net?.indexer) return null;
const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/token-balances`, { headers: { accept: "application/json" } });
if (!r.ok) throw new Error(`Blockscout tokens HTTP ${r.status}`);
const j = await r.json();
const list = Array.isArray(j) ? j : [];
return list.map((e) => {
const t = e?.token || {};
const symbol = cleanTokenText(t.symbol);
return {
mint: t.address_hash || t.address || "",
symbol: symbol || "?",
name: cleanTokenText(t.name),
decimals: Number(t.decimals) || 0,
known: !!symbol,
balance: String(e.value ?? "0"),
};
}).filter((t) => t.mint && t.balance !== "0")
.sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
},
},
trx: {
ticker: "TRX", decimals: 6,
networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" },
// nile.trongrid.io, NOT api.nileex.io: nileex only serves the
// /wallet/* JSON-RPC family and 404s the whole /v1/ REST family,
// which is where transaction history and the trc20 token list
// live. Balance worked, everything else silently came back empty.
nile: { id: "nile", label: "Nile testnet", rpc: "https://nile.trongrid.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" },
},
// Tron HTTP API returns account.balance in SUN (10^-6 TRX).
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ address, visible: true }) });
const j = await r.json();
return String(j?.balance || 0);
},
async fetchHistory({ rpc, address }) {
const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions?limit=25`);
if (!r.ok) throw new Error(`Tron history HTTP ${r.status}`);
const j = await r.json();
const list = Array.isArray(j?.data) ? j.data : [];
return list.map((t) => {
const c = t?.raw_data?.contract?.[0];
const v = c?.parameter?.value || {};
const ownerHex = String(v.owner_address || "");
// owner/to come back as 41-prefixed hex regardless of visible.
const mineHex = tronAddrToHex(address);
const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase();
const amount = Number(v.amount || 0);
const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true;
return {
txid: t.txID || t.txid,
time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000),
confirmations: ok ? 1 : 0,
status: ok ? "confirmed" : "failed",
// Aegis renders `delta` in the wallet's base unit (sun here).
delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0,
kind: c?.type || "Contract",
};
}).filter((t) => t.txid);
},
// TRC20 balances live on the /v1 REST family. The balance map is
// contract -> raw amount with no symbol/decimals, so we join it
// against token_info from recent transfers to name what we can.
async fetchTokens({ rpc, address }) {
const base = rpc.replace(/\/+$/, "");
const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`);
if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`);
const j = await r.json();
const acct = Array.isArray(j?.data) ? j.data[0] : j?.data;
const raw = Array.isArray(acct?.trc20) ? acct.trc20 : [];
const balances = new Map();
for (const entry of raw) {
for (const [contract, amt] of Object.entries(entry || {})) {
if (String(amt) !== "0") balances.set(contract, String(amt));
}
}
if (!balances.size) return [];
const info = new Map();
try {
const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`);
if (tr.ok) {
const tj = await tr.json();
for (const t of (Array.isArray(tj?.data) ? tj.data : [])) {
const ti = t?.token_info;
if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti);
}
}
} catch { /* names are a nicety; balances still render */ }
// Named tokens first: an address that's been airdrop-spammed can
// hold dozens of contracts we have no token_info for, and those
// would otherwise bury the ones the user actually cares about.
return Array.from(balances, ([contract, balance]) => {
const ti = info.get(contract);
const symbol = cleanTokenText(ti?.symbol);
return {
mint: contract,
symbol: symbol || "?",
name: cleanTokenText(ti?.name),
decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0,
known: !!ti && !!symbol,
balance,
};
}).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
},
},
sol: {
ticker: "SOL", decimals: 9,
networks: {
mainnet: { id: "mainnet", label: "Mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/" },
devnet: { id: "devnet", label: "Devnet", rpc: "https://api.devnet.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/", explorerSuffix: "?cluster=devnet", testnet: true, faucet: "https://faucet.solana.com/" },
},
// Solana JSON-RPC getBalance returns lamports as a number.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) });
const j = await r.json();
return String(j?.result?.value || 0);
},
// getSignaturesForAddress is keyless on the public RPC. It gives us
// the ledger of signatures touching this address but NOT the amounts —
// that would need a getTransaction per signature (25 extra round trips
// on every poll). We surface the entries with a null delta so the user
// at least sees activity and can open any of them in the explorer.
async fetchHistory({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getSignaturesForAddress", params: [address, { limit: 25 }] }) });
if (!r.ok) throw new Error(`Solana history HTTP ${r.status}`);
const j = await r.json();
if (j?.error) throw new Error(j.error.message || "getSignaturesForAddress failed");
const list = Array.isArray(j?.result) ? j.result : [];
return list.map((s) => ({
txid: s.signature,
time: Number(s.blockTime) || 0,
confirmations: s.confirmationStatus === "finalized" ? 1 : 0,
status: s.err ? "failed" : "confirmed",
delta: null,
kind: "Transaction",
})).filter((t) => t.txid);
},
// SPL balances via getTokenAccountsByOwner with jsonParsed, matching
// what the built-in Solana adapter does. Symbol/name aren't on-chain
// in the token account, so the mint stands in for the symbol.
async fetchTokens({ rpc, address }) {
const SPL = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const SPL22 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const call = async (programId) => {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner",
params: [address, { programId }, { encoding: "jsonParsed" }] }) });
if (!r.ok) throw new Error(`Solana tokens HTTP ${r.status}`);
const j = await r.json();
if (j?.error) throw new Error(j.error.message || "getTokenAccountsByOwner failed");
return Array.isArray(j?.result?.value) ? j.result.value : [];
};
const accounts = [].concat(...await Promise.all([
call(SPL).catch(() => []),
call(SPL22).catch(() => []),
]));
const out = [];
for (const a of accounts) {
const info = a?.account?.data?.parsed?.info;
const amt = info?.tokenAmount;
if (!info?.mint || !amt || String(amt.amount) === "0") continue;
out.push({
mint: String(info.mint),
symbol: String(info.mint).slice(0, 4) + "…",
name: "",
decimals: Number(amt.decimals) || 0,
known: true, // decimals ARE on-chain here, so the amount is real
balance: String(amt.amount),
});
}
return out.sort((a, b) => (a.mint || "").localeCompare(b.mint || "")).slice(0, TOKEN_CAP);
},
},
};
class GenericImportedWallet {
constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) {
const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`);
const net = cfg.networks[network]; if (!net) throw new Error(`chain-generic-imported: ${chain} has no network ${network}`);
if (!address) throw new Error("address required");
this.chain = chain;
this.network = network;
this._cfg = cfg;
this._net = { ...net, rpc: rpcUrl || net.rpc };
this.log = log;
this.onChange = onChange;
this._address = address;
this._state = {
balance: { confirmed: "0", unconfirmed: "0" },
history: [],
tokens: [],
scanning: false,
error: null,
};
this._pollTimer = null;
}
setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ }
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: this.chain, network: this.network,
ticker: this._cfg.ticker, decimals: this._cfg.decimals,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
history: this._state.history,
tokens: this._state.tokens,
scanning: this._state.scanning,
error: this._state.error,
server: this._net.rpc,
rpcUrl: this._net.rpc,
imported: true,
explorerAddr: this._net.explorerAddr,
explorerTx: this._net.explorerTx,
explorerSuffix: this._net.explorerSuffix || "",
faucet: this._net.faucet || null,
};
}
async refresh() {
this._state.scanning = true; this._emit();
const opts = { rpc: this._net.rpc, address: this._address, net: this._net };
try {
// Only the balance is load-bearing — history and tokens are
// best-effort so one 404 on a chain that has no keyless feed
// doesn't blank the wallet.
const [confirmed, history, tokens] = await Promise.all([
this._cfg.fetchBalance(opts),
this._cfg.fetchHistory
? this._cfg.fetchHistory(opts).catch((e) => { this.log("history failed:", e?.message || e); return null; })
: Promise.resolve(null),
this._cfg.fetchTokens
? this._cfg.fetchTokens(opts).catch((e) => { this.log("tokens failed:", e?.message || e); return null; })
: Promise.resolve(null),
]);
this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" };
if (Array.isArray(history)) this._state.history = history;
if (Array.isArray(tokens)) this._state.tokens = tokens;
this._state.error = null;
} catch (e) {
this._state.error = e?.message || String(e);
} finally {
this._state.scanning = false;
this._emit();
}
}
nextAddress() { return { address: this._address, index: 0 }; }
current() { return { address: this._address, index: 0, branch: 0, path: null }; }
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); }
signAndBroadcast() { throw new Error("read-only"); }
signMessage() { throw new Error("read-only"); }
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
dispose() { clearTimeout(this._pollTimer); }
}
return { GenericImportedWallet, CHAIN_CFGS };
};

View file

@ -20,6 +20,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
const keysLib = require("./sia/keys.js")({ sia });
const walletd = require("./sia/walletd.js")({ log: () => {} });
const walletFactory = require("./sia/wallet.js");
const siascanLib = require("./sia/siascan.js")({ log: () => {} });
function scopedStorage(storage, keyPrefix) {
const k = (key) => keyPrefix + key;
@ -32,7 +33,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
class SiaWallet {
constructor(root32, {
walletId, storage, log = () => {}, onChange = () => {},
walletdUrl = "",
walletdUrl = "", siascanUrl = "",
} = {}) {
if (!walletId) throw new Error("chain-sia: walletId required");
this.walletId = walletId;
@ -44,9 +45,67 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
this._root = new Uint8Array(root32);
this._keys = new keysLib.WalletKeys(root32);
this._walletdUrl = String(walletdUrl || "").trim();
this._siascanUrl = String(siascanUrl || "").trim() || siascanLib.DEFAULT_BASE;
this._client = null;
this._wallet = null;
// 0.7.5: siascan is the read-only fallback when no walletd URL is
// set. Users get balance + history + broadcast (v2) with zero
// hosting on their side, and can still point at their own walletd
// if they want to run everything sovereign.
this._siascan = new siascanLib.SiascanClient(this._siascanUrl);
this._siascanState = {
balance: { confirmed: "0", unconfirmed: "0", immature: "0" },
history: [],
height: 0,
addressIndex: 0,
scanning: false,
error: null,
};
this._siascanTimer = null;
if (this._walletdUrl) this._build();
else this._startSiascanPoll();
}
_stopSiascanPoll() { clearTimeout(this._siascanTimer); this._siascanTimer = null; }
_startSiascanPoll(intervalMs = 45_000) {
this._stopSiascanPoll();
const tick = async () => {
try { await this._refreshFromSiascan(); }
catch (e) { this._siascanState.error = e?.message || String(e); this._emitChange(); }
this._siascanTimer = setTimeout(tick, intervalMs);
};
// Initial fire is immediate — users see a balance without a poll wait.
this._siascanTimer = setTimeout(tick, 200);
}
_emitChange() { try { this.onChange(); } catch {} }
async _refreshFromSiascan() {
// Poll for the current receive index (default 0). SiaWallet's own
// "nextAddress" logic is walletd-scoped; without walletd we track
// the index in storage so the snapshot address stays stable.
const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0;
const entry = this._keys.entry(idx);
this._siascanState.scanning = true; this._emitChange();
try {
const [tip, bal, events] = await Promise.all([
this._siascan.tip().catch(() => ({ height: 0 })),
this._siascan.balance(entry.address),
this._siascan.events(entry.address, { limit: 25 }).catch(() => []),
]);
this._siascanState.height = tip.height;
this._siascanState.balance = {
confirmed: bal.confirmed,
unconfirmed: bal.unconfirmed,
immature: bal.immature,
};
this._siascanState.history = siascanLib.normaliseEvents(events, entry.address, tip.height);
this._siascanState.addressIndex = idx;
this._siascanState.error = null;
} finally {
this._siascanState.scanning = false;
this._emitChange();
}
}
_build() {
@ -65,21 +124,44 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
const v = String(url || "").trim();
if (v === this._walletdUrl) return;
this._walletdUrl = v;
if (v) this._build(); else { try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; }
if (v) {
// Switching to walletd: stop siascan polling — walletd owns the
// read path now.
this._stopSiascanPoll();
this._build();
} else {
// Dropping walletd URL: shut down the walletd wallet and resume
// siascan polling so the panel keeps a live balance.
try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null;
this._startSiascanPoll();
}
}
// The panel treats Sia amounts as decimal strings of hastings; the
// display layer picks how many SC-precision digits to show.
snapshot() {
const w = this._wallet && this._wallet.snapshot();
const usingSiascan = !this._wallet;
const siascanIdx = this._siascanState.addressIndex;
const siascanEntry = usingSiascan ? this._keys.entry(siascanIdx) : null;
const base = {
chain: "sc", network: "mainnet", ticker: "SC", decimals: 24,
address: null, addressIndex: 0, addressPath: `KeyFromSeed(seed, ${w?.addressIndex || 0})`,
address: null, addressIndex: 0,
addressPath: `KeyFromSeed(seed, ${w?.addressIndex || siascanIdx || 0})`,
balance: { confirmed: "0", unconfirmed: "0" },
height: 0, history: [], scanning: false, error: null,
server: this._client ? this._client.displayUrl : null,
// Server line the panel prints under the balance. When walletd is
// set that's the walletd URL; otherwise it's the siascan endpoint
// (which reads as public infrastructure, matching what's happening
// under the hood — no seed-material leaves the machine).
server: this._client ? this._client.displayUrl : (usingSiascan ? this._siascanUrl : null),
walletdUrl: this._walletdUrl,
needsWalletdUrl: !this._walletdUrl,
// 0.7.5: needsWalletdUrl no longer gates the wallet. Siascan handles
// read + broadcast automatically; the field stays for callers that
// want to nudge users toward self-hosted infrastructure.
needsWalletdUrl: false,
siascanUrl: usingSiascan ? this._siascanUrl : null,
readMode: usingSiascan ? "siascan" : "walletd",
explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null,
};
if (w) {
@ -100,21 +182,43 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
}));
base.scanning = w.scanning;
base.error = w.error;
} else if (siascanEntry) {
base.address = siascanEntry.address;
base.addressIndex = siascanIdx;
base.balance = {
confirmed: this._siascanState.balance.confirmed,
unconfirmed: this._siascanState.balance.unconfirmed,
};
base.height = this._siascanState.height;
base.history = this._siascanState.history;
base.scanning = this._siascanState.scanning;
base.error = this._siascanState.error;
}
return base;
}
async refresh(full) {
if (!this._wallet) return;
return this._wallet.refresh(!!full);
if (this._wallet) return this._wallet.refresh(!!full);
// Siascan path: fetch now, don't wait for the poll tick.
return this._refreshFromSiascan();
}
nextAddress() {
if (!this._wallet) throw new Error("no walletd URL configured");
return this._wallet.nextUnusedAddress();
if (this._wallet) return this._wallet.nextUnusedAddress();
// Siascan path: bump the stored receive index and re-poll. The next
// snapshot round-trips through _refreshFromSiascan which reads the
// updated storage value.
const cur = Number(this.storage.get("siascan/receiveIndex", 0)) || 0;
const nxt = cur + 1;
this.storage.set("siascan/receiveIndex", nxt);
this._refreshFromSiascan().catch(() => {});
const entry = this._keys.entry(nxt);
return { address: entry.address, index: nxt };
}
current() {
if (!this._wallet) throw new Error("no walletd URL configured");
return this._wallet.current();
if (this._wallet) return this._wallet.current();
const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0;
const entry = this._keys.entry(idx);
return { address: entry.address, index: idx, path: `KeyFromSeed(seed, ${idx})`, pub: entry.pub };
}
plan(spec) {
if (!this._wallet) throw new Error("no walletd URL configured");
@ -162,8 +266,12 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
xprv: this._keys.seedHex,
};
}
startPolling() { if (this._wallet) this._wallet.startPolling(60_000); }
startPolling() {
if (this._wallet) this._wallet.startPolling(60_000);
else this._startSiascanPoll();
}
dispose() {
this._stopSiascanPoll();
try { this._wallet && this._wallet.dispose(); } catch {}
try { this._keys && this._keys.wipe(); } catch {}
if (this._root) this._root.fill(0);

View file

@ -109,10 +109,10 @@ module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore,
async refresh(full) {
this._state.scanning = true; this._emit();
try {
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
if (full) {
const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]);
const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0,
}));

View file

@ -7,10 +7,14 @@
// npm packages — same "hand it in" pattern the other adapters use.
module.exports = function makeImportDerive({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256,
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39,
dgbCore,
}) {
// Sia's key derivation lives in lib/sia/sia.js — reuse it here so
// imported SC wallets end up with byte-identical addresses to what
// Sia Central Lite or walletd would show for the same seed.
const sia = blake2b ? require("./sia/sia.js")({ ed25519, blake2b }) : null;
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const fromHex = (h) => {
const s = String(h || "").replace(/^0x/i, "");
@ -207,6 +211,42 @@ module.exports = function makeImportDerive({
return base58check.encodeBase58(pub);
}
// ---- SC (Siacoin) --------------------------------------------------------
// Sia's walletd + Sia Central Lite Wallet both use a 32-byte root seed.
// Sia Central Lite exports it as a BIP39 12-word mnemonic (PBKDF2 →
// 64-byte seed → first 32 bytes = root); walletd's API accepts the raw
// 32-byte hex. Address at index N: standardUnlockHash(ed25519.pub(
// blake2b(root32 || u64le(N))
// )) — see lib/sia/sia.js:keyFromSeed for the byte layout.
function deriveScRootFromMnemonic(m) {
// BIP39 → 512-bit master seed; Sia Central takes the FIRST 32 bytes as
// the walletd root. Trimming the tail keeps addresses identical to
// what sialite.com and Sia Central mobile derive for the same phrase.
const fullSeedHex = mnemonicToSeedHex(m);
return fullSeedHex.slice(0, 64);
}
function deriveScRootFromHex(seedHex) {
const s = String(seedHex || "").trim().toLowerCase().replace(/^0x/, "");
if (!/^[0-9a-f]{64}$/.test(s)) throw new Error("SC seed hex must be exactly 32 bytes (64 hex chars)");
return s;
}
function deriveScAddressFromSeed(seedHex, index) {
if (!sia) throw new Error("SC derive unavailable (blake2b dep not passed)");
const root = fromHex(seedHex);
if (root.length !== 32) throw new Error("SC root seed must be 32 bytes");
const idx = Number(index || 0);
if (!Number.isInteger(idx) || idx < 0) throw new Error("SC index must be a non-negative integer");
const k = sia.keyFromSeed(root, idx);
return k.address; // 76 hex chars, canonical Sia address form
}
function deriveScFromMnemonic(mnemonic, index) {
return { seedHex: deriveScRootFromMnemonic(mnemonic), address: deriveScAddressFromSeed(deriveScRootFromMnemonic(mnemonic), index) };
}
function deriveScFromSeedHex(seedHex, index) {
const s = deriveScRootFromHex(seedHex);
return { seedHex: s, address: deriveScAddressFromSeed(s, index) };
}
return {
mnemonicToSeedHex,
btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif },
@ -214,5 +254,6 @@ module.exports = function makeImportDerive({
eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex },
trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex },
sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 },
sc: { fromMnemonic: deriveScFromMnemonic, fromSeedHex: deriveScFromSeedHex, addressAt: deriveScAddressFromSeed },
};
};

View file

@ -62,5 +62,57 @@ module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashad
try { this._account.wipePrivateData(); } catch {}
}
}
return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex };
// BIP-137 verification. Given a message, a 65-byte recoverable signature
// (base64, produced by signRecoverable above or Electron Cash / any other
// BCH tool), and a CashAddr, recover the signer's pubkey, hash it to the
// address's h160, and compare. Returns { valid, address, recoveredHash }.
// Deliberately pure (no wallet state) so a panel can verify a sig pasted
// from anywhere without touching the vault.
function verifyMessage(message, base64Signature, address, { cashaddr: caLib, secp256k1: sec }) {
const dec = (b64) => {
const bin = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
const u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
};
const sig = dec(String(base64Signature || "").trim());
if (sig.length !== 65) throw new Error(`signature must be 65 bytes (got ${sig.length})`);
const header = sig[0];
// BIP-137 header layout: 27 + recid + 4 (compressed). 0..3 → uncompressed,
// 4..7 → uncompressed P2SH-P2WPKH, 8..11 → uncompressed native-segwit,
// 12..15 → compressed. Every P2PKH BCH signer we care about uses the
// 31..34 range (27 + recid + 4). Anything outside 27..34 is rejected.
if (header < 27 || header > 34) throw new Error(`bad signature header ${header}`);
const recid = (header - 27) & 3;
const compressed = header >= 31;
const enc = new TextEncoder();
const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("message too long"); return Uint8Array.from([b.length, ...b]); };
const MAGIC = "Bitcoin Signed Message:\n";
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
const digest = sha256(sha256(payload));
// Reconstruct the raw signature (1-byte recid || r || s) for
// secp256k1.recoverPublicKey. @noble/curves takes the recovered format
// whether we pass compressed or uncompressed, we ask for compressed
// (matches every BCH wallet's derived pubkey).
const recovered = new Uint8Array(65);
recovered[0] = recid;
recovered.set(sig.subarray(1), 1);
const pub = sec.getPublicKey
? sec.recoverPublicKey(digest, recovered, { prehash: false, format: compressed ? "compressed" : "uncompressed" })
: sec.Signature.fromCompact(sig.subarray(1)).addRecoveryBit(recid).recoverPublicKey(digest).toRawBytes(compressed);
const recoveredHash = hash160(pub);
// Decode the expected address to its h160 payload; accept both mainnet
// and testnet prefixes. Rejects non-P2PKH addresses (type != 0) since
// this signing scheme has no notion of a P2SH signer.
const raw = String(address || "");
const full = raw.includes(":") ? raw : "bitcoincash:" + raw;
const { type, hash } = caLib.decode(full);
if (type !== 0) throw new Error(`address must be P2PKH (got type ${type})`);
const valid = recoveredHash.length === hash.length
&& recoveredHash.every((b, i) => b === hash[i]);
return { valid, address: raw, recoveredHash: toHex(recoveredHash) };
}
return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex, verifyMessage };
};

View file

@ -1,32 +1,39 @@
// Fiat prices for every Aegis-supported coin. Opt-in via Settings so a
// privacy-conscious user isn't quietly telling ANY oracle when Aegis is
// open. Source is user-selectable — different oracles trade off privacy,
// coverage, and freshness:
// Fiat prices for every Aegis-supported coin. Poll every enabled source in
// parallel and reconcile per chain: if two or more sources agree within a
// small band (±3% of the median), take their median as the truth; if none
// agree, fall back to the median of every reported value. This kills any
// single oracle's ability to make Aegis show a wrong number — a spoofed
// or wildly stale feed on one origin is outvoted by the others.
//
// - coingecko : one HTTP request covers all 7 coins, best coverage,
// default. Sees the browser IP + User-Agent every poll.
// - kraken : per-pair spot from Kraken's public /Ticker; fewer
// pairs (BCH/BTC/ETH/SOL/TRX; no SC/DGB). Sees IP but
// no user id.
// - coinbase : Coinbase's public spot endpoint; similar coverage to
// Kraken, similar IP-only exposure.
// The user-facing model in 0.6.36+ is just "on / off": no source picker,
// no per-source config. Adding a new oracle here fans out to everyone
// with no UI churn.
//
// New sources plug in by adding an entry to SOURCES. Each provider takes a
// list of chain keys and returns { <chain>: usd } for the ones it knows
// about; unknown chains just stay absent from the snapshot. The poller is
// generic.
// Sources currently wired:
// coingecko — 1 request covers all 7 coins, best overall coverage
// kraken — public /Ticker; BCH/BTC/ETH/SOL/TRX pairs
// coinbase — public /spot; BCH/BTC/ETH/SOL pairs
// coinspectrum — coin-spectrum.com free /assets/<slug>.json (~10 min TTL)
//
// Cache is in-memory (returned by fullState() → panel). Poll interval is
// per-source since some rate-limit tighter than others. Off by default.
// Sources deferred (need their own protocol work first):
// oracles.cash / General Protocols — the /oracleMetadata endpoint returns
// hex-encoded signed attestations. Extracting a usable USD number
// requires decoding the message format (pair || timestamp || price_int
// || decimals) and verifying the signature against a known oracle
// pubkey per pair. Left as a TODO stub below so the plumbing is
// ready; enable once the message parser is done.
const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"];
// Sources return { <chain>: usd_number } for every chain they know about.
// Absence just means "this source doesn't cover that chain"; reconciliation
// ignores it. Errors thrown here bubble to the poller which stores them
// per-source in the snapshot so the panel can show which oracle is down.
const SOURCES = {
coingecko: {
id: "coingecko",
label: "CoinGecko",
origin: "api.coingecko.com",
pollMs: 5 * 60 * 1000,
coversAll: true,
fetch: async () => {
const ids = {
@ -49,18 +56,14 @@ const SOURCES = {
id: "kraken",
label: "Kraken",
origin: "api.kraken.com",
pollMs: 60 * 1000,
coversAll: false,
fetch: async () => {
// Kraken uses non-standard pair names (XBT, ZUSD…). Only cover the
// coins Kraken lists with USD spot. SC + DGB are not on Kraken.
const pairs = { bch: "BCHUSD", btc: "XBTUSD", eth: "ETHUSD", sol: "SOLUSD", trx: "TRXUSD" };
const url = `https://api.kraken.com/0/public/Ticker?pair=${Object.values(pairs).join(",")}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`);
const body = await r.json();
if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";"));
// Kraken returns keys like "XBCHZUSD" — match by suffix.
const out = {};
const result = body?.result || {};
const entries = Object.entries(result);
@ -76,11 +79,8 @@ const SOURCES = {
id: "coinbase",
label: "Coinbase",
origin: "api.coinbase.com",
pollMs: 60 * 1000,
coversAll: false,
fetch: async () => {
// Coinbase publishes one spot per pair via /v2/prices/<pair>/spot.
// Runs the requests in parallel — 5 calls, each ~150 B response.
const map = { bch: "BCH-USD", btc: "BTC-USD", eth: "ETH-USD", sol: "SOL-USD" };
const out = {};
await Promise.all(Object.entries(map).map(async ([chain, pair]) => {
@ -95,35 +95,154 @@ const SOURCES = {
return out;
},
},
coinspectrum: {
id: "coinspectrum",
label: "Coin-Spectrum",
origin: "coin-spectrum.com",
coversAll: true,
fetch: async () => {
const slugs = {
bch: "bitcoin-cash", btc: "bitcoin", trx: "tron",
eth: "ethereum", sol: "solana", sc: "siacoin", dgb: "digibyte",
};
const out = {};
await Promise.all(Object.entries(slugs).map(async ([chain, slug]) => {
try {
const r = await fetch(`https://coin-spectrum.com/api/v1/assets/${slug}.json`, { cache: "no-store" });
if (!r.ok) return;
const body = await r.json();
// coin-spectrum wraps everything under body.asset:
// { generated_at, asset: { slug, symbol, price_usd, … } }
// Older builds read body.price_usd, which is undefined, so every
// chain silently NaN'd and the UI showed "✓ 0 coins".
const usd = Number(body?.asset?.price_usd ?? body?.price_usd);
if (Number.isFinite(usd) && usd > 0) out[chain] = usd;
} catch { /* one slug failing shouldn't kill the others */ }
}));
return out;
},
},
// oracles.cash (General Protocols) is deferred until we decode their
// signed-attestation message format. Enable by moving this entry into
// SOURCES above once fetch() returns real USD numbers.
// _oraclescash: {
// id: "oraclescash",
// label: "oracles.cash",
// origin: "oracles.generalprotocols.com",
// coversAll: false,
// fetch: async () => {
// // TODO: pick per-pair oracle pubkey, fetch /api/v1/oracleMessages,
// // decode `message` = pair_ascii(2 bytes) || timestamp(u32) ||
// // price_int(u32-or-u64) || decimals; verify signature. See
// // https://oracles.generalprotocols.com/api/v1/oracleMetadata for
// // the list of active oracles.
// return {};
// },
// },
};
const DEFAULT_SOURCE = "coingecko";
const POLL_MS = 5 * 60 * 1000; // 5 min: gentle on free tiers, still fresh enough
const AGREEMENT_BAND = 0.03; // ±3% around the median counts as "agreeing"
const median = (nums) => {
const s = nums.slice().sort((a, b) => a - b);
const m = s.length;
if (!m) return null;
return m % 2 ? s[(m - 1) / 2] : (s[m / 2 - 1] + s[m / 2]) / 2;
};
// Reconcile a per-source map for ONE chain into a single trusted USD number.
// perSource: { <sourceId>: usd_number }
// Returns { usd, method, samples: [{sourceId, usd, agrees}] }.
function reconcileOne(perSource) {
const samples = Object.entries(perSource)
.filter(([, v]) => Number.isFinite(v) && v > 0)
.map(([sourceId, usd]) => ({ sourceId, usd, agrees: false }));
if (!samples.length) return { usd: null, method: "none", samples };
if (samples.length === 1) {
samples[0].agrees = true;
return { usd: samples[0].usd, method: "single", samples };
}
// Pin agreement around the overall median so no single outlier can shift
// the anchor. Any two samples within ±3% of that median form a "cluster";
// if ≥2 exist we take their median as the truth.
const mid = median(samples.map((s) => s.usd));
const lo = mid * (1 - AGREEMENT_BAND);
const hi = mid * (1 + AGREEMENT_BAND);
const agreeing = samples.filter((s) => s.usd >= lo && s.usd <= hi);
if (agreeing.length >= 2) {
for (const a of agreeing) a.agrees = true;
return { usd: median(agreeing.map((s) => s.usd)), method: `majority-${agreeing.length}of${samples.length}`, samples };
}
// Nobody agrees within the band — every source disagrees. Fall back to
// the median of everything reported so we still show A price (biased
// toward the middle) rather than nothing. Panel can show a "spread"
// warning if callers care.
return { usd: mid, method: `median-${samples.length}`, samples };
}
// Fan out to every SOURCES.fetch() in parallel. Returns
// { perChain: { <chain>: {usd, method, samples} }, sourceStatus: { <id>: {ok, error, prices, at} } }.
async function pollAll(log) {
const sourceStatus = {};
const perSourcePrices = {}; // chain -> {sourceId: usd}
await Promise.all(Object.values(SOURCES).map(async (s) => {
const start = Date.now();
try {
const got = await s.fetch();
const prices = got && typeof got === "object" ? got : {};
sourceStatus[s.id] = { ok: true, error: null, prices, at: Date.now(), took: Date.now() - start };
for (const [chain, usd] of Object.entries(prices)) {
if (!Number.isFinite(usd) || usd <= 0) continue;
if (!perSourcePrices[chain]) perSourcePrices[chain] = {};
perSourcePrices[chain][s.id] = usd;
}
} catch (e) {
const msg = e?.message || String(e);
sourceStatus[s.id] = { ok: false, error: msg, prices: {}, at: Date.now(), took: Date.now() - start };
log(`price fetch (${s.id}) failed:`, msg);
}
}));
const perChain = {};
for (const chain of CHAINS) {
const rec = reconcileOne(perSourcePrices[chain] || {});
if (rec.usd != null) perChain[chain] = rec;
}
return { perChain, sourceStatus };
}
module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
const state = {
enabled: false,
source: DEFAULT_SOURCE,
prices: {}, // { <chain>: usd (number) }
prices: {}, // { <chain>: usd (number) } — backward-compat
reconciled: {}, // { <chain>: {usd, method, samples: [...]} }
sourceStatus: {}, // { <sourceId>: {ok, error, prices, at, took} }
fetchedAt: null,
error: null,
loading: false,
};
let timer = null;
function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; }
async function fetchOnce() {
if (!state.enabled) return;
state.loading = true; state.error = null; onChange();
try {
const src = currentProvider();
const next = await src.fetch();
state.prices = next || {};
const { perChain, sourceStatus } = await pollAll(log);
const flat = {};
for (const [chain, rec] of Object.entries(perChain)) flat[chain] = rec.usd;
state.prices = flat;
state.reconciled = perChain;
state.sourceStatus = sourceStatus;
state.fetchedAt = Date.now();
state.error = null;
// Only escalate to a top-level error if EVERY source failed. A single
// oracle being unreachable is normal and doesn't need a red banner.
const allDown = Object.values(sourceStatus).every((s) => !s.ok);
state.error = allDown ? "All price sources unreachable" : null;
} catch (e) {
state.error = e?.message || String(e);
log(`price fetch (${state.source}) failed:`, state.error);
log("price poll failed:", state.error);
} finally {
state.loading = false;
onChange();
@ -133,30 +252,31 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
function schedule() {
clearTimeout(timer);
if (!state.enabled) return;
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs);
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
}
return {
snapshot() {
return {
enabled: state.enabled,
source: state.source,
prices: state.prices,
reconciled: state.reconciled,
sourceStatus: state.sourceStatus,
fetchedAt: state.fetchedAt,
error: state.error,
loading: state.loading,
sources: Object.values(SOURCES).map((s) => ({
id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll,
})),
// Retained so existing settings UI paths that expect a `sources`
// list keep rendering. `coversAll` is informational only now that
// the picker's gone.
sources: Object.values(SOURCES).map((s) => ({ id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll })),
};
},
// Turn the feed on/off. Enabling triggers an immediate fetch so the
// panel doesn't wait a full poll interval for the first price.
async setEnabled(on) {
const changed = !!on !== state.enabled;
state.enabled = !!on;
if (!state.enabled) {
state.prices = {}; state.fetchedAt = null; state.error = null;
state.prices = {}; state.reconciled = {}; state.sourceStatus = {};
state.fetchedAt = null; state.error = null;
clearTimeout(timer);
if (changed) onChange();
return;
@ -165,15 +285,10 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
await fetchOnce();
schedule();
},
// Switch source. Clears the current cache, kicks a fresh fetch if the
// feed is enabled. No-op when the source is already current.
async setSource(id) {
if (!SOURCES[id] || id === state.source) return;
state.source = id;
state.prices = {}; state.fetchedAt = null;
onChange();
if (state.enabled) { await fetchOnce(); schedule(); }
},
// No-op kept for API compatibility — there is no source picker anymore.
// Existing callers that persisted a chosen source can still call this
// and get a benign refresh.
async setSource(_id) { if (state.enabled) { await fetchOnce(); schedule(); } },
refresh() { return fetchOnce(); },
dispose() { clearTimeout(timer); state.enabled = false; },
};

View file

@ -0,0 +1,156 @@
// Siascan (SiaFoundation/explored) public read-only client. Used by
// SiaWallet when the user has NOT pointed Aegis at their own walletd
// URL — with a live siascan endpoint we can render balance, unspent
// outputs, transaction history, and the chain tip without any hosting
// on the user's side.
//
// Endpoints (from SiaFoundation/explored api/server.go):
// GET /consensus/tip
// GET /addresses/{addr}/balance
// GET /addresses/{addr}/events
// GET /addresses/{addr}/events/unconfirmed
// GET /addresses/{addr}/utxos/siacoin
// POST /txpool/broadcast — broadcast a v1 tx
// POST /v2/transactions — batch fetch (not broadcast; broadcast is v1)
//
// Broadcast (send) still requires walletd today: the tx we build is a v2
// transaction and siascan's broadcast is currently v1-only. Once the v2
// broadcast endpoint lands upstream this same client picks it up.
const DEFAULT_BASE = "https://api.siascan.com";
module.exports = function makeSiascan({ log = () => {} } = {}) {
class SiascanClient {
constructor(baseUrl) {
this._base = String(baseUrl || DEFAULT_BASE).replace(/\/+$/, "");
}
get displayUrl() { return this._base; }
setBase(url) { this._base = String(url || DEFAULT_BASE).replace(/\/+$/, ""); }
async _get(path) {
const url = this._base + path;
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) {
const body = await r.text().catch(() => "");
throw new Error(`siascan ${r.status} ${path}: ${body.slice(0, 200)}`);
}
return r.json();
}
// Chain tip. Used to compute confirmations on history events.
async tip() {
const j = await this._get("/consensus/tip");
return { height: Number(j?.height || 0), id: String(j?.id || "") };
}
// Wallet-agnostic balance for one address. Returns hastings as decimal
// strings so the panel keeps the BigInt-safe wire format the walletd
// path already emits.
async balance(address) {
const j = await this._get(`/addresses/${encodeURIComponent(address)}/balance`);
// explored shape: { siacoins, immatureSiacoins, siafunds }
// Each is a hastings string (v2 currency serialisation).
return {
confirmed: String(j?.siacoins || "0"),
immature: String(j?.immatureSiacoins || "0"),
// Aegis's panel treats "unconfirmed" as "not yet spendable". Explored
// lumps immature payout there; a strict unconfirmed number would
// need the /events/unconfirmed sum instead — added below.
unconfirmed: String(j?.immatureSiacoins || "0"),
siafunds: Number(j?.siafunds || 0),
};
}
// Confirmed history events. Each event carries a type ("v2Transaction",
// "siacoinInput", "minerPayout", …), the amount delta from THIS address's
// perspective, and a maturity/block height.
async events(address, { limit = 25, offset = 0 } = {}) {
const q = `?limit=${limit}&offset=${offset}`;
const list = await this._get(`/addresses/${encodeURIComponent(address)}/events${q}`);
return Array.isArray(list) ? list : [];
}
async unconfirmedEvents(address) {
const list = await this._get(`/addresses/${encodeURIComponent(address)}/events/unconfirmed`);
return Array.isArray(list) ? list : [];
}
// Unspent Siacoin outputs. { id, siacoinOutput: {value, address}, maturityHeight }
async siacoinUtxos(address) {
const list = await this._get(`/addresses/${encodeURIComponent(address)}/utxos/siacoin`);
return Array.isArray(list) ? list : [];
}
// Broadcast a v2 transaction (or a set). explored's POST /txpool/broadcast
// takes { transactions: [v1…], v2Transactions: [v2…] } — we only ever
// send the v2 form (Aegis's tx builder is v2-only). Returns nothing
// on success; a 200 means "accepted into the pool".
async broadcastV2(v2TxOrSet) {
const set = Array.isArray(v2TxOrSet) ? v2TxOrSet : [v2TxOrSet];
const url = this._base + "/txpool/broadcast";
const body = JSON.stringify({ transactions: [], v2Transactions: set });
const r = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body,
});
if (!r.ok) {
const errBody = await r.text().catch(() => "");
throw new Error(`siascan broadcast ${r.status}: ${errBody.slice(0, 250)}`);
}
// explored responds 200 with an empty body on success; nothing to
// parse. Caller derives the txid client-side from the signed tx.
return true;
}
}
// Compute a per-event delta for the SUBJECT address. explored returns
// rich event structures; we normalise to Aegis's { txid, delta, to,
// confirmations, time } row shape. delta is a signed BigInt-safe string.
// Positive = received, negative = spent.
function normaliseEvents(rawEvents, subjectAddress, tipHeight) {
const out = [];
for (const ev of rawEvents || []) {
const kind = String(ev?.type || "");
const height = Number(ev?.index?.height || ev?.maturityHeight || 0);
const confirmations = height && tipHeight ? Math.max(0, tipHeight - height + 1) : 0;
// Sum outputs to us minus inputs from us.
let received = 0n, spent = 0n, other = null;
const dat = ev?.data || {};
const outputs = dat?.siacoinOutputs || dat?.transaction?.siacoinOutputs || [];
const inputs = dat?.siacoinInputs || dat?.transaction?.siacoinInputs || [];
for (const o of outputs) {
const addr = o?.siacoinOutput?.address || o?.address || null;
const val = toBigStr(o?.siacoinOutput?.value || o?.value);
if (addr === subjectAddress) received += BigInt(val);
else if (!other) other = addr;
}
for (const i of inputs) {
const addr = i?.parent?.siacoinOutput?.address || i?.address || null;
const val = toBigStr(i?.parent?.siacoinOutput?.value || i?.value);
if (addr === subjectAddress) spent += BigInt(val);
}
const delta = (received - spent).toString();
out.push({
txid: String(ev?.id || ""),
delta,
to: (BigInt(delta) < 0n && other) ? other : null,
from: null,
fee: null,
time: Number(ev?.timestamp || 0),
confirmations,
status: confirmations > 0 ? "confirmed" : "pending",
kind,
});
}
return out;
}
function toBigStr(x) {
if (typeof x === "string") return x;
if (typeof x === "bigint") return x.toString();
return String(x || "0");
}
return { SiascanClient, normaliseEvents, DEFAULT_BASE };
};

View file

@ -37,7 +37,22 @@ module.exports = function makeTx({ sha256 }) {
const outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout));
const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE;
const feeFor = (nIn, nOut, satPerByte) => Math.ceil(estimateSize(nIn, nOut) * satPerByte);
// OP_RETURN data outputs vary — approximate with 12 + payload bytes to
// keep the fee estimate honest without threading a full byte size through.
const estimateSizeWithData = (nIn, nOut, dataBytes) => estimateSize(nIn, nOut) + (dataBytes ? 12 + dataBytes : 0);
const feeFor = (nIn, nOut, satPerByte, dataBytes = 0) => Math.ceil(estimateSizeWithData(nIn, nOut, dataBytes) * satPerByte);
// Build a memo protocol OP_RETURN script from a plain UTF-8 string. Layout:
// 0x6a OP_RETURN
// [pushdata] memo bytes (up to 220 to stay under standardness)
// The output's value is always 0 and it's flagged { data: true } so the
// dust check in select() skips it. Callers can pass raw bytes if they
// want to embed a non-UTF8 payload; strings are the common case.
function memoScript(input) {
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input || 0);
if (bytes.length > 220) throw new Error(`memo too long: ${bytes.length} bytes (max 220)`);
return concat(Uint8Array.from([0x6a]), pushdata(bytes));
}
// inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }]
// outputs: [{ value, script(Uint8Array) }]
@ -81,42 +96,51 @@ module.exports = function makeTx({ sha256 }) {
return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) };
}
// Largest-first accumulation. `targets` = [{ value, script }]; returns
// { inputs, outputs, fee, change } or throws when funds don't cover it.
// sendMax: spend every UTXO into targets[0] and no change.
// Largest-first accumulation. `targets` = [{ value, script, data? }]:
// data:true — an OP_RETURN memo output; value MUST be 0 and doesn't
// count toward the send amount or the dust check.
// sendMax spends every UTXO into the sole non-data target with no change.
// Data outputs are preserved verbatim in every returned outputs array.
function select(utxos, targets, satPerByte, changeScript, { sendMax = false } = {}) {
const dataOuts = targets.filter((t) => t.data);
const spendOuts = targets.filter((t) => !t.data);
const dataBytes = dataOuts.reduce((a, t) => a + (t.script?.length || 0), 0);
const sorted = utxos.slice().sort((a, b) => b.value - a.value);
const total = sorted.reduce((a, u) => a + u.value, 0);
if (sendMax) {
if (targets.length !== 1) throw new Error("send max needs exactly one recipient");
const fee = feeFor(sorted.length, 1, satPerByte);
if (spendOuts.length !== 1) throw new Error("send max needs exactly one recipient");
const fee = feeFor(sorted.length, 1 + dataOuts.length, satPerByte, dataBytes);
const value = total - fee;
if (!sorted.length || value < DUST) throw new Error("balance too small to send");
return { inputs: sorted, outputs: [{ value, script: targets[0].script }], fee, change: 0 };
return { inputs: sorted, outputs: [{ value, script: spendOuts[0].script }, ...dataOuts], fee, change: 0 };
}
const want = targets.reduce((a, t) => a + t.value, 0);
for (const t of targets) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
const want = spendOuts.reduce((a, t) => a + t.value, 0);
for (const t of spendOuts) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
const chosen = []; let sum = 0;
for (const u of sorted) {
chosen.push(u); sum += u.value;
const feeWithChange = feeFor(chosen.length, targets.length + 1, satPerByte);
const feeWithChange = feeFor(chosen.length, spendOuts.length + dataOuts.length + 1, satPerByte, dataBytes);
if (sum >= want + feeWithChange) {
const change = sum - want - feeWithChange;
if (change >= DUST) {
return { inputs: chosen, outputs: [...targets, { value: change, script: changeScript }], fee: feeWithChange, change };
return {
inputs: chosen,
outputs: [...spendOuts, { value: change, script: changeScript }, ...dataOuts],
fee: feeWithChange, change,
};
}
// Change would be dust: fold it into the fee, one output fewer.
const fee = sum - want;
return { inputs: chosen, outputs: targets.slice(), fee, change: 0 };
return { inputs: chosen, outputs: [...spendOuts, ...dataOuts], fee, change: 0 };
}
const feeNoChange = feeFor(chosen.length, targets.length, satPerByte);
const feeNoChange = feeFor(chosen.length, spendOuts.length + dataOuts.length, satPerByte, dataBytes);
if (sum >= want + feeNoChange && sum - want - feeNoChange < DUST) {
return { inputs: chosen, outputs: targets.slice(), fee: sum - want, change: 0 };
return { inputs: chosen, outputs: [...spendOuts, ...dataOuts], fee: sum - want, change: 0 };
}
}
const short = want + feeFor(Math.max(1, sorted.length), targets.length + 1, satPerByte) - total;
const short = want + feeFor(Math.max(1, sorted.length), spendOuts.length + dataOuts.length + 1, satPerByte, dataBytes) - total;
throw new Error(`insufficient funds: need about ${short} more sat`);
}
return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, toHex, fromHex, dsha };
return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, memoScript, toHex, fromHex, dsha, concat, pushdata };
};

View file

@ -1,6 +1,15 @@
// Wallet state machine on top of an electrum client and a WalletKeys tree:
// address discovery (gap limit), balance, history with per-tx deltas, UTXO
// set and send construction. Knows nothing about UI or IPC.
//
// 0.7.0: CashTokens read + coin-selection guard. Every UTXO fetched from
// listunspent is enriched with its scriptPubKey and passed through
// cashtokens.decodePrefixedScript. Token UTXOs are tagged { token: {…} }
// and pooled into state.tokenBalances (category → aggregate); they are
// deliberately EXCLUDED from plain-BCH coin selection so no token UTXO
// gets accidentally spent (and its category burned) on a routine send.
const cashtokens = require("./cashtokens.js");
module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) {
const GAP = 20;
const HISTORY_LIMIT = 25;
@ -11,7 +20,8 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
watched: new Map(), // scripthash -> entry
height: 0,
balance: { confirmed: 0, unconfirmed: 0 },
utxos: [], // { txid, vout, value, height, entry }
utxos: [], // { txid, vout, value, height, entry, token? }
tokenBalances: {}, // { <categoryHex>: { fungible: bigint, nfts: [...], utxoIds: [...] } }
history: [], // newest first
receiveIndex: 0,
scanning: false,
@ -70,12 +80,70 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
async function loadUtxos() {
const lists = await Promise.all([...state.watched.values()].map(async (e) => {
const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]);
return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e }));
return (Array.isArray(u) ? u : []).map((x) => ({
txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e,
}));
}));
state.utxos = lists.flat();
let confirmed = 0, unconfirmed = 0;
for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; }
state.balance = { confirmed, unconfirmed };
const utxos = lists.flat();
// Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript
// can classify it. getTx() already caches to disk, so a re-scan on a
// wallet with hundreds of UTXOs only fetches new ones. Failures are
// tolerated — an un-classifiable UTXO is treated as bare BCH, which
// is the conservative choice (worst case: user sees BCH value in
// balance but the coin selector still won't pick it if its token
// status matters — it just won't participate in a token send either).
const tokenBalances = {};
await Promise.all(utxos.map(async (u) => {
try {
const t = await getTx(u.txid);
const out = t.vout[u.vout];
if (!out || !out.scriptHex) return;
const scriptBytes = tx.fromHex(out.scriptHex);
const { token, lockingScript } = cashtokens.decodePrefixedScript(scriptBytes);
u.scriptHex = out.scriptHex;
u.lockingScriptHex = Array.from(lockingScript, (x) => x.toString(16).padStart(2, "0")).join("");
if (token) {
u.token = token;
const cat = token.categoryHex;
if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] };
if (token.hasAmount) tokenBalances[cat].fungible += token.amount;
if (token.hasNft) {
tokenBalances[cat].nfts.push({
utxoId: `${u.txid}:${u.vout}`,
commitmentHex: token.commitmentHex,
capability: token.capability,
capabilityLabel: token.capabilityLabel,
});
}
tokenBalances[cat].utxoIds.push(`${u.txid}:${u.vout}`);
}
} catch (e) {
log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e);
}
}));
state.utxos = utxos;
// Serialize BigInt fungible amounts as decimal strings for the snapshot
// (JSON.stringify chokes on BigInt otherwise).
const serializedBalances = {};
for (const [cat, bal] of Object.entries(tokenBalances)) {
serializedBalances[cat] = {
fungible: bal.fungible.toString(),
nfts: bal.nfts,
utxoCount: bal.utxoIds.length,
};
}
state.tokenBalances = serializedBalances;
// Balance number is BCH sat only — token UTXOs still carry a small
// BCH value (dust minimum for the prefix), but treating that as
// spendable would let a routine send burn the token. Track total
// separately as bareBalance so the panel can still show "there's
// BCH sitting in token UTXOs".
let confirmed = 0, unconfirmed = 0, tokenLocked = 0;
for (const u of utxos) {
if (u.token) { tokenLocked += u.value; continue; }
if (u.height > 0) confirmed += u.value; else unconfirmed += u.value;
}
state.balance = { confirmed, unconfirmed, tokenLocked };
}
async function getTx(txid) {
@ -197,7 +265,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
}
// targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan.
function plan({ targets, feeRate = 1, sendMax = false }) {
// memo: optional string (UTF-8, ≤220 bytes) — attached as an OP_RETURN
// data output. Zero value, no dust check, fee estimate accounts
// for the extra bytes. Passing "" disables the memo.
function plan({ targets, feeRate = 1, sendMax = false, memo = "" }) {
const rate = Math.min(10, Math.max(1, Number(feeRate) || 1));
const outs = targets.map((t) => {
const a = cashaddr.parseAny(t.to, sha256, keys.prefix);
@ -206,10 +277,25 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
: Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
});
// Spend confirmed coins first; unconfirmed only when needed.
const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
if (memo) outs.push({ value: 0, script: tx.memoScript(memo), data: true, memo });
// Spend confirmed coins first; unconfirmed only when needed. Token
// UTXOs are excluded entirely — burning a category by dropping its
// prefix is not a mistake we can undo, so a plain BCH send must
// never pull one. Token sends have their own code path with
// { includeToken: category } later.
const spendable = state.utxos
.filter((u) => !u.token)
.slice()
.sort((a, b) => (b.height > 0) - (a.height > 0));
const sel = tx.select(spendable, outs, rate, changeEntry().script, { sendMax });
return { ...sel, feeRate: rate, recipients: outs.map((o, i) => ({ to: o.to, value: sel.outputs[i].value })) };
// recipients only lists spendable (non-data) outputs, keeping the
// panel's summary honest — the memo is surfaced separately as .memo.
const spendable_outs = sel.outputs.filter((o) => !o.data);
return {
...sel, feeRate: rate,
recipients: spendable_outs.map((o, i) => ({ to: outs[i]?.to, value: o.value })),
memo: memo || null,
};
}
async function signAndBroadcast(p) {
@ -232,6 +318,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
height: state.height,
history: state.history,
utxoCount: state.utxos.length,
// CashTokens balances, keyed by category hex. Empty object when the
// wallet holds no token UTXOs. Serialised BigInts (fungible amounts)
// come across as decimal strings — panel formats via BigInt again.
tokenBalances: state.tokenBalances,
scanning: state.scanning,
error: state.error,
};

View file

@ -37,9 +37,11 @@
.bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; }
.bal .sub { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.bal .sub .netlbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Full-panel sheet — fills the sidebar so long wallet lists and the
import form aren't squeezed into a small popover. */
#drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 0;
/* Full-panel sheet — fills most of the sidebar but stops above the
footer so the aegis.x brand + version chip stay visible. Users
opening + should still know which build they're on and be able
to hit the update button. */
#drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 30px;
background: var(--panel); border-top: 1px solid var(--line);
box-shadow: 0 -6px 26px rgba(0,0,0,.35); z-index: 20;
display: flex; flex-direction: column; }
@ -66,9 +68,37 @@
#drop .netgroup[hidden] { display: none; }
#drop .netchoice { padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); }
#drop .netchoice:hover { background: rgba(255,255,255,.06); color: var(--ink); }
/* Currency-browse network chip row (0.8.0). Sits above the wallet list
under a coin; clicking a chip switches which subnetwork's wallets
are visible AND persists the choice per chain. */
#drop .brnetrow { display: flex; gap: 6px; padding: 8px 8px 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); }
#drop .brnet { background: transparent; border: 1px solid var(--line); color: var(--mut);
border-radius: 999px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer;
display: inline-flex; align-items: center; gap: 4px; }
#drop .brnet:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); }
#drop .brnet.on { background: rgb(from var(--acid, #d6ff3d) r g b / .14); border-color: rgb(from var(--acid, #d6ff3d) r g b / .55);
color: var(--acid, #d6ff3d); font-weight: 600; }
#drop .brnet .hint { color: currentColor; opacity: .65; font-size: 11px; }
.ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px;
background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; }
#hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; }
/* 0.8.4: hNet became a proper chip on its own row (.netrow) so the
network the wallet is on is easy to spot AND easy to switch. */
.netrow { display: flex; margin-top: 6px; padding: 0 2px; }
.netchip { background: rgba(255,255,255,.05); border: 1px solid var(--line);
color: var(--mut); border-radius: 999px; padding: 3px 10px 3px 8px;
font: inherit; font-size: 11.5px; cursor: pointer; display: inline-flex;
align-items: center; gap: 6px; line-height: 1.2; }
.netchip:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); }
.netchip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .7; }
/* Currency-picker search + all-coins catalogue (0.8.4). */
#drop .pickersearch { padding: 8px; border-bottom: 1px solid var(--line); }
#drop .pickersearch input { width: 100%; box-sizing: border-box; background: rgba(255,255,255,.04);
border: 1px solid var(--line); color: var(--ink); border-radius: 7px;
padding: 7px 10px; font: inherit; font-size: 12.5px; outline: none; }
#drop .pickersearch input:focus { border-color: rgb(from var(--acid, #d6ff3d) r g b / .55); }
#drop .catgroup { padding: 4px 6px 2px 10px; color: var(--dim); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; }
#drop .row.unowned { opacity: .8; }
#drop .row.unowned .v { color: var(--acid, #d6ff3d); font-weight: 600; font-size: 11px; }
.fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; }
.portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; }
.portfolio b { color: var(--ink); font-weight: 600; }
@ -78,6 +108,39 @@
padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1;
display: inline-flex; align-items: center; justify-content: center; }
.chip:hover { border-color: var(--acid); color: var(--acid); }
/* Edit-this-wallet button living inside the label group. Compact and
borderless so it reads as an inline affordance, not a separate
action chip. Fades in on hover of the label row so the header
itself stays visually quiet when the user isn't targeting it. */
.editchip { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 4px; border-radius: 4px; font: inherit; font-size: 12px; line-height: 1;
opacity: .5; transition: opacity .12s, color .12s; margin-left: 2px; }
.picker:hover .editchip { opacity: 1; }
.editchip:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Send/Receive mode toggle (0.8.0). Segmented control at the top of a
tab; picks whether the body shows the normal flow or the consolidate
inline picker. */
.modetoggle { display: flex; gap: 0; border: 1px solid var(--line); border-radius: 8px; padding: 3px;
margin-bottom: 12px; background: rgba(255,255,255,.03); }
.modetoggle[hidden] { display: none; }
.modetoggle button { flex: 1; background: transparent; border: 0; color: var(--dim);
padding: 6px 8px; font: inherit; font-size: 12.5px; border-radius: 6px;
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
.modetoggle button.on { background: rgb(from var(--acid, #d6ff3d) r g b / .16); color: var(--acid, #d6ff3d); font-weight: 600; }
.modetoggle button:hover:not(.on) { color: var(--ink); }
.modetoggle .hint { color: currentColor; opacity: .65; font-size: 11px; }
/* Settings section chip nav (0.8.2). Segmented row that pages between
Security / Session / Wallet / Prices / Sites / About cards without
scrolling through the whole tab. */
.setsecnav { display: flex; flex-wrap: wrap; gap: 4px; border: 1px solid var(--line);
border-radius: 8px; padding: 3px; margin-bottom: 14px;
background: rgba(255,255,255,.03); }
.setsecnav button { flex: 1 0 auto; min-width: 62px; background: transparent; border: 0;
color: var(--dim); padding: 6px 10px; font: inherit; font-size: 12px;
border-radius: 6px; cursor: pointer; }
.setsecnav button.on { background: rgb(from var(--acid, #d6ff3d) r g b / .16);
color: var(--acid, #d6ff3d); font-weight: 600; }
.setsecnav button:hover:not(.on) { color: var(--ink); }
nav { display: flex; border-bottom: 1px solid var(--line); background: var(--panel); }
nav button { flex: 1; padding: 9px 0 8px; border: 0; background: transparent; color: var(--mut); cursor: pointer;
font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; }
@ -262,6 +325,14 @@
.wstrip .wcname .wnetpill.wchipnet { background: rgb(from var(--acid, #d6ff3d) r g b / .18);
color: var(--acid, #d6ff3d); font-weight: 600; }
.wstrip .wcname .wnetpill.wtestnet { background: rgba(224,179,65,.18); color: #e0b341; font-weight: 600; }
/* Multi-wallet count pill inside the ticker cell. When the group has
more than one wallet it doubles as the "swap active wallet" trigger
(▾ chevron), independent of the row's own click target which now
selects the current active wallet directly. */
.wstrip .wcname .wgcount.wgpick { cursor: pointer; }
.wstrip .wcname .wgcount.wgpick:hover { background: rgba(255,255,255,.12); color: var(--acid, #d6ff3d); }
.wstrip .wcname .wgcount .wgchev { color: var(--dim); font-size: 9px; margin-left: 1px; }
.wstrip .wcname .wgcount.wgpick:hover .wgchev { color: var(--acid, #d6ff3d); }
.wstrip .wcname .wgcount { color: var(--dim); font-size: 10.5px; padding: 0 6px; border-radius: 999px;
background: rgba(255,255,255,.06); font-variant-numeric: tabular-nums; line-height: 1.4; }
.wstrip .wcname .wcprice { color: var(--acid, #d6ff3d); font-size: 11px; font-weight: 600;
@ -313,21 +384,86 @@
.wstrip .wcoinhead .wctitle { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .wcoinhead .wcount { color: var(--dim); font-size: 11.5px; font-weight: 500; }
.wstrip .warow { display: grid; grid-template-columns: 16px minmax(60px,1fr) auto auto auto;
gap: 6px; align-items: center; padding: 5px 4px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 30px; }
/* Inline address-list row (per-coin drilldown). 0.6.35 layout: strictly
columnar so every row's fields line up in the same x positions no
matter how long each label happens to be. The address column is the
only flex one (minmax 0/1fr) — it fills whatever's left after the
fixed cells and truncates with an ellipsis, so a wider sidebar shows
more of the address without the label/balance jumping around. Every
other column has an explicit width — label pill is fixed to 68px so
"a" and "anthem…" occupy the same slot, amount is fixed to 90px so
"0" and "0.000123" right-align identically, actions are fixed to
46px. Net result: columns look like a table, not a flex mess. */
.wstrip .warow { display: grid;
grid-template-columns: 16px minmax(0,1fr) 22px 68px 90px 46px;
gap: 6px; align-items: center; padding: 4px 4px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 28px; }
.wstrip .warow:hover { background: rgba(255,255,255,.04); }
.wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35); }
.wstrip .warow .waname { font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-weight: 500; }
.wstrip .warow .waaddr { font: 11px/1.15 ui-monospace, Consolas, monospace; color: var(--dim); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--ink); }
.wstrip .warow .wafiat { font-size: 11px; color: var(--dim); }
.wstrip .warow .waaddr { font: 12px/1.15 ui-monospace, Consolas, monospace; color: var(--ink);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* Copy chip anchored right after the address. Fixed 22px column so the
copy button sits at the same x on every row. */
.wstrip .warow .wacopy { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 4px; border-radius: 4px; font-size: 11px; line-height: 1;
transition: color .12s; justify-self: start; }
.wstrip .warow .wacopy:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
.wstrip .warow .wacopy.copied { color: var(--acid, #d6ff3d); }
/* Label pill: fixed 68px column. Even an empty label renders an
invisible placeholder so the amount column stays put. Long labels
truncate with ellipsis inside the pill (max-width: 100% of column). */
.wstrip .warow .walabel { font-size: 11px; color: var(--mut);
padding: 1px 6px; border-radius: 999px;
background: rgba(255,255,255,.06);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 100%; box-sizing: border-box;
justify-self: center; }
.wstrip .warow .walabel:empty { visibility: hidden; }
/* Balance shares a single line with everything else — no vertical
amount/fiat stack. Ticker follows the number in a dim tone so the
row reads "0 BCH" at a glance. Right-aligned within the fixed
amount column so short and long numbers line up. */
.wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums;
font-size: 12.5px; color: var(--ink); white-space: nowrap;
display: inline-flex; align-items: baseline; gap: 4px;
justify-self: end; overflow: hidden; }
.wstrip .warow .waamt .watkr { color: var(--dim); font-size: 11px; font-weight: 500; }
.wstrip .warow .wact { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 5px; border-radius: 4px; font-size: 12.5px; line-height: 1; }
.wstrip .warow .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Destructive strip action (remove wallet). Stays quiet until hovered so
a row of icons doesn't read as a row of warnings. */
.wstrip .wact.wactdel:hover { color: var(--danger, #f6768a); background: rgb(from var(--danger, #f6768a) r g b / .12); }
/* Per-address asset list (0.8.8). The count chip expands the row into a
nested list of what that ONE address holds beyond the native coin. */
.wstrip .waassets { background: rgba(255,255,255,.06); border: 0; color: var(--dim); cursor: pointer;
font: inherit; font-size: 10px; padding: 1px 6px; border-radius: 999px; line-height: 1.5; }
.wstrip .waassets:hover, .wstrip .waassets.on { color: var(--acid, #d6ff3d); background: rgb(from var(--acid, #d6ff3d) r g b / .14); }
.wstrip .waassetlist { padding: 2px 6px 6px 26px; display: flex; flex-direction: column; gap: 2px; }
.wstrip .waasset { display: grid; grid-template-columns: minmax(0,1fr) auto auto; gap: 8px; align-items: baseline;
font-size: 11px; color: var(--mut); padding: 2px 4px; border-radius: 4px; }
.wstrip .waasset:hover { background: rgba(255,255,255,.04); }
.wstrip .waasset .waaname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .waasset .waaid { color: var(--dim); font-size: 10px; }
.wstrip .waasset .waaamt { font-variant-numeric: tabular-nums; color: var(--ink); }
/* Coin drilldown header: shows the coin's per-unit price + running
total of the addresses below, so the aggregate context isn't lost
when the user is deep in the per-address view. */
.wstrip .wcoinsub { display: flex; align-items: baseline; gap: 8px; padding: 2px 4px 5px 4px;
font-size: 11.5px; color: var(--mut); border-bottom: 1px solid var(--line);
margin-bottom: 3px; }
.wstrip .wcoinsub .wprice { color: var(--acid, #d6ff3d); font-weight: 600; font-variant-numeric: tabular-nums;
text-shadow: 0 0 5px rgb(from var(--acid, #d6ff3d) r g b / .30); }
.wstrip .wcoinsub .wsep { color: var(--dim); }
.wstrip .wcoinsub .wtot { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 500; }
.wstrip .wcoinsub .wtotfiat { color: var(--dim); font-variant-numeric: tabular-nums; }
/* Adapter-error line above the address rows. Shown only when at least
one wallet has a non-null .error — makes silent RPC failures visible
instead of the display quietly rendering 0. */
.wstrip .wcoinerr { color: #e05a5a; font-size: 11px; padding: 4px 6px;
background: rgba(224,90,90,.08); border-radius: 4px;
margin-bottom: 4px; overflow-wrap: anywhere; }
/* Full-panel lock screen — takes over the entire panel below the aegis
footer when the vault is locked or awaiting first-time setup. Rest of
@ -343,10 +479,17 @@
filter: drop-shadow(0 0 12px rgb(from var(--acid, #d6ff3d) r g b / .35)); }
#lockScreen h1 { font: 600 16px/1.3 inherit; margin: 0 0 4px 0; letter-spacing: .2px; }
#lockScreen .subhint { color: var(--mut); font-size: 12px; max-width: 320px; margin: 0 0 20px 0; }
#lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: left; }
/* The form inherits the lock screen's centred alignment — it used to
force text-align:left, which left the setup screen's helper copy
running ragged against a centred title, mark and description. */
#lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: center; }
#lockScreen .lockform input[type=password],
#lockScreen .lockform input[type=text],
#lockScreen .lockform textarea { text-align: center; }
#lockScreen .lockform input[type=text] { text-align: center; }
/* The mnemonic stays left-aligned on purpose: 12/24 words wrap across
several lines, and centring makes them ragged on both edges, which is
exactly the wrong thing when someone is checking a seed word by word. */
#lockScreen .lockform textarea { text-align: left; }
#lockScreen .lockform .hint { text-align: center; }
#lockScreen .altline { color: var(--dim); font-size: 11.5px; margin-top: 12px; text-align: center; }
#lockScreen .altline a { color: var(--acid, #d6ff3d); cursor: pointer; text-decoration: none; }
#lockScreen .altline a:hover { text-decoration: underline; }
@ -396,14 +539,25 @@
<div class="t">
<span class="badge" id="hBadge"></span>
<span class="lbl" id="hLabel">Aegis Wallet</span>
<span id="hNet"></span>
<!-- Edit chip sits inline with the wallet name so it reads as
"edit THIS wallet". Was in picker-actions on the far right
until 0.7.2, which mixed poorly with the global + button. -->
<button type="button" id="hManage" class="editchip" title="Edit this wallet — rename, derivation path, remove">✎</button>
</div>
<div class="picker-actions">
<button type="button" id="hAdd" class="chip" title="Add a new wallet">+</button>
<button type="button" id="hMore" class="chip" title="Import / Connect / About">⋯</button>
<button type="button" id="hManage" class="chip" title="Edit this wallet — rename, derivation path, remove">✎</button>
<!-- Single right-corner chip. Opens a compact popover menu with
Create new / Import / Connect / About — replacing the old
three-chip cluster (+ ⋯ ✎). Method chosen there routes into
the coin picker inside #drop or an appropriate modal. -->
<button type="button" id="hAdd" class="chip" title="Add or import a wallet">+</button>
</div>
</div>
<!-- 0.8.4: network picker is its own row below the coin label so it
reads as "the network I'm on right now" instead of a tiny suffix
next to the wallet name. Click cycles / opens the network menu. -->
<div class="netrow" id="hNetRow" hidden>
<button type="button" class="netchip" id="hNet" title="Switch network"></button>
</div>
<div id="drop" hidden></div>
<div class="bal">
<div class="big">
@ -432,6 +586,15 @@
<div id="gate" class="gate" hidden></div>
<div id="tabs">
<section id="tab-receive">
<!-- Segmented mode toggle (0.8.0). Switches the tab body between
the normal Receive view (QR + address) and the Consolidate
view (inline picker for sweeping other-wallet balances into
this one). Hidden when there is nothing to consolidate. -->
<div id="rcvModeToggle" class="modetoggle" hidden>
<button data-rcv-mode="receive" class="on" type="button">Receive</button>
<button data-rcv-mode="consolidate" type="button">Consolidate <span id="rcvConsolidateCount" class="hint"></span></button>
</div>
<div id="rcvNormal">
<div class="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
<div class="card">
<div class="lbl">Receiving address <span id="addrMeta"></span></div>
@ -446,10 +609,30 @@
<div class="card" id="tokensCard" hidden style="margin-top:12px">
<div class="lbl">Tokens</div>
<div id="tokensList" class="kv"></div>
<div class="hint">SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.</div>
<div class="hint" id="tokensHint">Tokens held by this wallet.</div>
</div>
</div><!-- /rcvNormal -->
<!-- Consolidate view (inline, replaces Receive body when active). -->
<div id="rcvConsolidate" hidden>
<div id="rcvConsolidateInline"></div>
</div>
</section>
<section id="tab-send" hidden>
<!-- Send/Consolidate mode toggle (0.8.0). Same pattern as Receive. -->
<div id="sendModeToggle" class="modetoggle" hidden>
<button data-send-mode="send" class="on" type="button">Send</button>
<button data-send-mode="consolidate" type="button">Consolidate <span id="sendConsolidateCount" class="hint"></span></button>
</div>
<div id="sendNormal">
<!-- Legacy chip retained but hidden — 0.7.7's chip is replaced by
the mode toggle above. Kept in the DOM so existing panel.js
code that reads it doesn't NPE mid-migration. -->
<div id="consolidateChipWrap" hidden style="margin-bottom:12px">
<button id="consolidateChip" class="btn sm" type="button" style="width:100%;text-align:left;display:flex;align-items:center;gap:8px;justify-content:space-between">
<span>⇢ Consolidate balances into <b id="consolidateChipDest">this wallet</b></span>
<span class="hint" id="consolidateChipCount"></span>
</button>
</div>
<div class="field" id="sendAssetField" hidden>
<div class="lbl">Asset</div>
<select id="sendAsset"></select>
@ -472,6 +655,15 @@
<div class="lbl">Fee</div>
<div class="fee"><input type="range" id="feeRate" min="1" max="5" step="1" value="1"><span class="v" id="feeLbl">1 sat/B</span></div>
</div>
<!-- Optional OP_RETURN memo. BCH-only for now (UTXO chains only);
the field hides when a non-BCH wallet is selected. 220 byte cap
keeps the tx under standardness relay policy. Memo is public on
the ledger — the placeholder text warns before someone types. -->
<div class="field" id="memoField" hidden>
<div class="lbl">Memo <span class="hint" style="margin-left:8px;font-weight:normal">optional · public on-chain · ≤220 bytes</span></div>
<input type="text" id="sendMemo" maxlength="220" spellcheck="false" autocomplete="off"
placeholder="e.g. Invoice #1234 — visible to anyone">
</div>
<div class="card">
<div class="summary">
<div class="k">Amount</div><div class="v" id="sumAmt">—</div>
@ -481,11 +673,29 @@
</div>
<div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div>
<div class="msg" id="sendMsg" hidden></div>
</div><!-- /sendNormal -->
<div id="sendConsolidate" hidden>
<div id="sendConsolidateInline"></div>
</div>
</section>
<section id="tab-history" hidden>
<div id="txlist"></div>
</section>
<section id="tab-settings" hidden>
<!-- 0.8.2: Settings sectioned. Chip row at top picks which section
is visible; each section wraps a group of related cards. The
chain-specific cards (bchSettings / trxSettings / …) sit
inside the "Wallet" section and their own hidden-vs-shown
toggle (per selected chain) still applies within it. -->
<div class="setsecnav" id="setsecnav">
<button data-setsec="security" class="on" type="button">Security</button>
<button data-setsec="session" type="button">Session</button>
<button data-setsec="wallet" type="button">Wallet</button>
<button data-setsec="prices" type="button">Prices</button>
<button data-setsec="sites" type="button">Sites</button>
<button data-setsec="about" type="button">About</button>
</div>
<div data-setsec-body="security">
<!-- Cross-cutting security / policy controls, ahead of anything
per-wallet or chain-specific. Present even when the vault is
still locked so users can set up a PIN or read the multi-sig
@ -515,6 +725,8 @@
</div>
</div>
</div><!-- /security section (multi-sig card lives inside it below) -->
<div data-setsec-body="session" hidden>
<!-- Session: stay-signed-in + idle auto-lock + manual sign-out.
Uses electron.safeStorage under the hood so the master password
is only decryptable under this OS user account. When "Lock on
@ -570,7 +782,9 @@
</div>
</div>
</div>
</div><!-- /session section -->
<div data-setsec-body="security" hidden>
<div class="card gsec" id="genMultiSig" style="margin-bottom:14px">
<div class="gtitle">Second-device approval <span class="gsoon">soon</span></div>
<div class="gline">
@ -583,7 +797,9 @@
</div>
</div>
</div>
</div><!-- /security-multisig -->
<div data-setsec-body="wallet" hidden>
<div class="card" id="walletManage" style="margin-bottom:14px">
<div class="lbl">This wallet</div>
<div class="field" style="margin-top:6px;margin-bottom:8px">
@ -771,18 +987,18 @@
</div>
</div>
<div class="card" style="margin-top:16px">
</div><!-- /wallet section -->
<div data-setsec-body="prices" hidden>
<div class="card" style="margin-top:0">
<div class="lbl">Fiat prices</div>
<div class="hint" style="margin-bottom:10px">
Off by default. When enabled, Aegis polls the chosen oracle for USD prices
on each supported coin. No keys and no address data are ever sent — but the
oracle sees your IP + a User-Agent every poll, which is a signal that a
wallet is open on this machine.
</div>
<div class="field">
<div class="lbl">Oracle</div>
<select id="pricesSource" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px"></select>
<div class="hint" id="pricesSourceHint"></div>
Off by default. When enabled, Aegis polls every supported oracle in
parallel and reconciles the results — two or more sources agreeing
within ±3% form the trusted price, so no single oracle can quietly
make Aegis show a wrong number. Every enabled oracle sees your IP
+ a User-Agent every poll, which is a signal that a wallet is open
on this machine.
</div>
<div class="actions" style="align-items:center;gap:12px">
<label class="switch">
@ -792,13 +1008,41 @@
<button class="btn sm" id="refreshPrices" hidden>Refresh now</button>
<span class="hint" id="pricesStatus" style="margin-left:auto"></span>
</div>
<!-- Per-source status: which oracles are up, which are down, and
the last per-chain reconciliation method (majority / median /
single). Populated from the priceFeed snapshot. -->
<div id="pricesSources" class="hint" style="margin-top:10px"></div>
</div>
<!-- Hidden legacy select so panel.js code that reads .value doesn't
NPE while we transition; select stays hidden and empty. -->
<select id="pricesSource" hidden></select>
<span id="pricesSourceHint" hidden></span>
</div><!-- /prices section -->
<div class="card" style="margin-top:16px">
<div data-setsec-body="sites" hidden>
<div class="card" style="margin-top:0">
<div class="lbl">Connected sites</div>
<div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div>
<div id="sites" class="kv"></div>
</div>
</div><!-- /sites section -->
<div data-setsec-body="about" hidden>
<div class="card" style="margin-top:0">
<div class="lbl">About Aegis</div>
<div class="hint" style="margin-bottom:10px">
Aegis is Silent Mode's built-in multi-chain wallet. Keys are derived
from your Theseus vault — same vault, every wallet across every
chain. Aegis lives at <a class="link" id="aboutOpenAegisSite">aegis.x</a> and
this build's version + update chip sit at the bottom-right of the
panel.
</div>
<div class="hint">
Silent Mode — <a class="link" id="aboutOpenSilentmodeSite">silentmode.st</a> ·
docs on the plugin system live at <span class="mono">theseus.x/plug-ins</span>.
</div>
</div>
</div><!-- /about section -->
<div class="msg err" id="settingsMsg" hidden></div>
</section>
</div>
@ -825,15 +1069,16 @@
<svg viewBox="0 0 32 32" width="14" height="14" aria-hidden="true"><polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="16" cy="16" r="4.3" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="16" cy="16" r="1.3" fill="currentColor"/></svg>
<span>aegis.x</span>
</a>
<!-- Update controls: a subtle "check" link that polls the OTA manifest,
then swaps to an "Update to vX.Y.Z" chip when a newer version is
ready. Panel-only view since the addon can't promote itself; the
chip links to Settings > Extensions > Aegis where the Apply flow
lives. Version marker on the far right doubles as the up-to-date
affordance so a happy panel says nothing extra. -->
<span id="brandUpdate" class="brandupd" hidden></span>
<!-- Update controls (0.8.1): the update chip lives INSIDE the right-side
group, sharing the slot with the version marker. paintFooterUpdate()
hides brandVer when a newer build is available and shows brandUpdate
in its place — clicking the acid-green "↑ v0.8.1" text stages the
download and relaunches Theseus. Idle: brandVer visible, brandUpdate
hidden. Same one-click Update/Install path the user asked for, at
the exact spot they identified. -->
<span class="brandfoot-right">
<button id="brandCheck" class="brandcheck" type="button" title="Check for updates">↻</button>
<span id="brandUpdate" class="brandupd" hidden></span>
<span class="brandver" id="brandVer"></span>
</span>
</footer>

File diff suppressed because it is too large Load diff

View file

@ -141,6 +141,36 @@ const tronLink = {
theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb);
theseus.contextBridge.exposeInMainWorld("tronLink", tronLink);
// -------- WizardConnect bridge (window.wizardconnect), everywhere -----------
//
// WizardConnect is a Nostr-relay pairing protocol built for CROSS-device use:
// the dapp calls initiateDappRelay(), gets a wiz:// uri, and renders it as a
// QR for a phone wallet to scan. On the same device that QR round-trip is
// pure friction — the dapp and Aegis are in the same browser.
//
// The SDK has no in-page wallet discovery, so this is Silent Mode's own
// surface. A dapp keeps its existing initiateDappRelay() call and simply
// hands us the uri it already generated:
//
// const { uri } = initiateDappRelay(onStatus);
// if (window.wizardconnect) await window.wizardconnect.connect(uri);
// else renderQr(uri); // unchanged fallback
//
// connect() resolves once the user approves the pairing in Aegis and the
// key exchange completes, and rejects if they decline. Nothing is paired
// without an explicit approval, and we never read the page to find a uri —
// the dapp hands it to us.
const wizardconnect = {
isAegis: true,
version: "1.0.0",
// Present so a dapp can tell "wallet is installed" from "wallet is
// installed but has no BCH wallet ready to pair with" before it decides
// whether to fall back to a QR.
isReady: () => call("wcPageReady"),
connect: (uri) => call("wcConnectFromPage", { uri: String(uri ?? "") }),
};
theseus.contextBridge.exposeInMainWorld("wizardconnect", wizardconnect);
// -------- Main-world bridges (window.ethereum, window.solana) ---------------
//
// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet
@ -491,6 +521,23 @@ const mainWorldSource = `(function () {
announce();
window.addEventListener("eip6963:requestProvider", announce);
} catch {}
// Same announce/request handshake for WizardConnect. The WC SDK defines
// no discovery mechanism at all, so we borrow EIP-6963's shape: a dapp
// that wants to support several WC wallets dispatches
// "wizardconnect:requestProvider" and collects the announcements instead
// of reaching for window.wizardconnect and finding whoever won the race.
// window.wizardconnect stays for the simple single-wallet case.
try {
const wcInfo = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
const announceWc = () => {
const provider = window.wizardconnect;
if (!provider) return;
window.dispatchEvent(new CustomEvent("wizardconnect:announceProvider", { detail: Object.freeze({ info: wcInfo, provider }) }));
};
announceWc();
window.addEventListener("wizardconnect:requestProvider", announceWc);
} catch {}
})();`;
// Actually push the script into the main world. Doing this at

File diff suppressed because it is too large Load diff

12430
main.js

File diff suppressed because it is too large Load diff

View file

@ -1,116 +1,116 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("theseus", {
navigate: (input) => ipcRenderer.invoke("navigate", input),
search: (q) => ipcRenderer.invoke("search", q),
newTab: () => ipcRenderer.invoke("new-tab"),
closeTab: (id) => ipcRenderer.invoke("close-tab", id),
switchTab: (id) => ipcRenderer.invoke("switch-tab", id),
moveTab: (id, targetId, place) => ipcRenderer.invoke("move-tab", id, targetId, place),
suggestAddress: (query, rect) => ipcRenderer.invoke("suggest-address", query, rect),
closeAddressPicker: () => ipcRenderer.invoke("close-address-picker"),
addressCursor: (dir) => ipcRenderer.invoke("address-cursor", dir),
togglePwFill: (rect) => ipcRenderer.invoke("toggle-pw-fill", rect),
onPwAvailability: (cb) => ipcRenderer.on("pw-availability", (_e, d) => cb(d)),
// Update chip: chrome subscribes to update-available; clicking the chip's
// download button opens the URL in the system browser; ✕ dismisses for
// the current session.
onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)),
// Signed extension updates staged by the background check: { staged:
// [{id,name,version}] }. They apply on the next launch, so the chrome
// offers a restart. stagedAddons() asks once at boot.
onAddonUpdates: (cb) => ipcRenderer.on("addon-updates", (_e, d) => cb(d)),
stagedAddons: () => ipcRenderer.invoke("addons-list-staged"),
applyStagedAddons: () => ipcRenderer.invoke("addons-apply-staged"),
restartApp: () => ipcRenderer.invoke("app-restart"),
openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url),
installUpdateNow: () => ipcRenderer.invoke("install-update-now"),
dismissUpdate: () => ipcRenderer.invoke("dismiss-update"),
goHome: () => ipcRenderer.invoke("go-home"),
back: () => ipcRenderer.invoke("go-back"),
forward: () => ipcRenderer.invoke("go-forward"),
// Press-and-hold / right-click on Back or Forward: native menu of the
// tab's history entries in that direction; picking one jumps to it.
navHistoryMenu: (dir, rect) => ipcRenderer.invoke("nav-history-menu", dir, rect),
reload: (hard) => ipcRenderer.invoke("reload", !!hard),
stop: () => ipcRenderer.invoke("stop"),
toggleTor: () => ipcRenderer.invoke("toggle-tor"),
openSettings: (section) => ipcRenderer.invoke("open-settings", section),
getSettings: () => ipcRenderer.invoke("settings-get"),
setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val),
onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)),
// Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer
// owns the bar UI and drives findInPage / stopFindInPage via these
// wrappers. Match count / active ordinal comes back through onFindResult.
onFindOpen: (cb) => ipcRenderer.on("find-open", () => cb()),
findInPage: (query, opts) => ipcRenderer.invoke("find-in-page", query, opts || {}),
findStop: () => ipcRenderer.invoke("find-stop"),
onFindResult: (cb) => ipcRenderer.on("find-result", (_e, d) => cb(d)),
toggleSiteInfo: (rect) => ipcRenderer.invoke("toggle-site-info", rect),
switchToBcnr: () => ipcRenderer.invoke("switch-to-bcnr"),
setChromeHeight: (h) => ipcRenderer.invoke("set-chrome-height", h),
getSearchEngines: () => ipcRenderer.invoke("search-engines"),
setSearchEngine: (id) => ipcRenderer.invoke("set-search-engine", id),
addEngine: (eng) => ipcRenderer.invoke("add-engine", eng),
removeEngine: (id) => ipcRenderer.invoke("remove-engine", id),
toggleEnginePicker: (rect) => ipcRenderer.invoke("toggle-engine-picker", rect),
onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)),
getBookmarks: () => ipcRenderer.invoke("bookmarks-get"),
addBookmark: (bm) => ipcRenderer.invoke("bookmark-add", bm),
updateBookmark: (url, patch) => ipcRenderer.invoke("bookmark-update", url, patch),
removeBookmark: (url) => ipcRenderer.invoke("bookmark-remove", url),
moveBookmark: (fromUrl, targetUrl, place) => ipcRenderer.invoke("bookmark-move", fromUrl, targetUrl, place),
onBookmarks: (cb) => ipcRenderer.on("bookmarks", (_e, d) => cb(d)),
onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)),
onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)),
onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)),
tabReload: (id) => ipcRenderer.invoke("tab-reload", id),
tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id),
tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on),
tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color),
tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color),
tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id),
onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)),
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),
// Collision-mode (BCNR ↔ ICANN) live switcher for the active tab
collisionSwitch: (arg) => ipcRenderer.invoke("collision-switch", arg),
collisionState: () => ipcRenderer.invoke("collision-state"),
// Downloads — the toolbar button subscribes to `downloads` to update its
// badge, and toggleDownloads opens/closes the floating panel.
getDownloads: () => ipcRenderer.invoke("downloads-get"),
toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect),
onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)),
// Add-on sidebar: toggle, open on a specific panel, close, or subscribe
// to state (visibility + which panel is active + the panel list). The
// extension dock in chrome.html renders one button per panel and calls
// openSidebar / closeSidebar accordingly.
toggleSidebar: () => ipcRenderer.invoke("sidebar-toggle"),
openSidebar: (panelId) => ipcRenderer.invoke("sidebar-open", panelId),
closeSidebar: () => ipcRenderer.invoke("sidebar-close"),
sidebarState: () => ipcRenderer.invoke("sidebar-state"),
onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)),
// Extension dock: drag-reorder (key = "p:<panelId>" | "m:<addonId>") and
// the right-click menu (native; main owns the actions).
dockMove: (key, targetKey, place) => ipcRenderer.invoke("dock-move", key, targetKey, place),
dockMenu: (key, rect) => ipcRenderer.invoke("dock-menu", key, rect),
// Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked
// item id to the add-on's "menu-select" handler.
addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId),
// Pop the dock-icon dropdown as a NATIVE menu. Renderer-side DOM popovers
// get clipped by chrome.html's own WebContentsView height (CHROME_H) and
// then covered by the tab view below it — a native Menu.popup escapes that
// layering entirely. Pass the button's viewport-rect so main can anchor.
toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect),
tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, rect),
// Ariadne's Thread menu behind the registry button at the end of the
// address bar (BCDN/ICANN switch, remembered choices, policy, settings).
registryMenuPopup: (rect) => ipcRenderer.invoke("registry-menu-popup", rect),
// Page zoom on the active tab: step +1 / -1 along Chrome's ladder, or reset to 100 %.
zoomStep: (dir) => ipcRenderer.invoke("zoom-step", dir),
zoomReset: () => ipcRenderer.invoke("zoom-reset"),
// Install-as-app chip in the address bar: main decides between the
// install dialog and the open/remove menu (anchored at rect).
webappChip: (rect) => ipcRenderer.invoke("webapp-chip", rect),
// Chrome listens so it can drop the "active" tint when the native menu
// dismisses (Esc, outside click, item picked — main fires all three).
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),
});
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("theseus", {
navigate: (input) => ipcRenderer.invoke("navigate", input),
search: (q) => ipcRenderer.invoke("search", q),
newTab: () => ipcRenderer.invoke("new-tab"),
closeTab: (id) => ipcRenderer.invoke("close-tab", id),
switchTab: (id) => ipcRenderer.invoke("switch-tab", id),
moveTab: (id, targetId, place) => ipcRenderer.invoke("move-tab", id, targetId, place),
suggestAddress: (query, rect) => ipcRenderer.invoke("suggest-address", query, rect),
closeAddressPicker: () => ipcRenderer.invoke("close-address-picker"),
addressCursor: (dir) => ipcRenderer.invoke("address-cursor", dir),
togglePwFill: (rect) => ipcRenderer.invoke("toggle-pw-fill", rect),
onPwAvailability: (cb) => ipcRenderer.on("pw-availability", (_e, d) => cb(d)),
// Update chip: chrome subscribes to update-available; clicking the chip's
// download button opens the URL in the system browser; ✕ dismisses for
// the current session.
onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)),
// Signed extension updates staged by the background check: { staged:
// [{id,name,version}] }. They apply on the next launch, so the chrome
// offers a restart. stagedAddons() asks once at boot.
onAddonUpdates: (cb) => ipcRenderer.on("addon-updates", (_e, d) => cb(d)),
stagedAddons: () => ipcRenderer.invoke("addons-list-staged"),
applyStagedAddons: () => ipcRenderer.invoke("addons-apply-staged"),
restartApp: () => ipcRenderer.invoke("app-restart"),
openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url),
installUpdateNow: () => ipcRenderer.invoke("install-update-now"),
dismissUpdate: () => ipcRenderer.invoke("dismiss-update"),
goHome: () => ipcRenderer.invoke("go-home"),
back: () => ipcRenderer.invoke("go-back"),
forward: () => ipcRenderer.invoke("go-forward"),
// Press-and-hold / right-click on Back or Forward: native menu of the
// tab's history entries in that direction; picking one jumps to it.
navHistoryMenu: (dir, rect) => ipcRenderer.invoke("nav-history-menu", dir, rect),
reload: (hard) => ipcRenderer.invoke("reload", !!hard),
stop: () => ipcRenderer.invoke("stop"),
toggleTor: () => ipcRenderer.invoke("toggle-tor"),
openSettings: (section) => ipcRenderer.invoke("open-settings", section),
getSettings: () => ipcRenderer.invoke("settings-get"),
setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val),
onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)),
// Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer
// owns the bar UI and drives findInPage / stopFindInPage via these
// wrappers. Match count / active ordinal comes back through onFindResult.
onFindOpen: (cb) => ipcRenderer.on("find-open", () => cb()),
findInPage: (query, opts) => ipcRenderer.invoke("find-in-page", query, opts || {}),
findStop: () => ipcRenderer.invoke("find-stop"),
onFindResult: (cb) => ipcRenderer.on("find-result", (_e, d) => cb(d)),
toggleSiteInfo: (rect) => ipcRenderer.invoke("toggle-site-info", rect),
switchToBcnr: () => ipcRenderer.invoke("switch-to-bcnr"),
setChromeHeight: (h) => ipcRenderer.invoke("set-chrome-height", h),
getSearchEngines: () => ipcRenderer.invoke("search-engines"),
setSearchEngine: (id) => ipcRenderer.invoke("set-search-engine", id),
addEngine: (eng) => ipcRenderer.invoke("add-engine", eng),
removeEngine: (id) => ipcRenderer.invoke("remove-engine", id),
toggleEnginePicker: (rect) => ipcRenderer.invoke("toggle-engine-picker", rect),
onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)),
getBookmarks: () => ipcRenderer.invoke("bookmarks-get"),
addBookmark: (bm) => ipcRenderer.invoke("bookmark-add", bm),
updateBookmark: (url, patch) => ipcRenderer.invoke("bookmark-update", url, patch),
removeBookmark: (url) => ipcRenderer.invoke("bookmark-remove", url),
moveBookmark: (fromUrl, targetUrl, place) => ipcRenderer.invoke("bookmark-move", fromUrl, targetUrl, place),
onBookmarks: (cb) => ipcRenderer.on("bookmarks", (_e, d) => cb(d)),
onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)),
onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)),
onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)),
tabReload: (id) => ipcRenderer.invoke("tab-reload", id),
tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id),
tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on),
tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color),
tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color),
tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id),
onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)),
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),
// Collision-mode (BCNR ↔ ICANN) live switcher for the active tab
collisionSwitch: (arg) => ipcRenderer.invoke("collision-switch", arg),
collisionState: () => ipcRenderer.invoke("collision-state"),
// Downloads — the toolbar button subscribes to `downloads` to update its
// badge, and toggleDownloads opens/closes the floating panel.
getDownloads: () => ipcRenderer.invoke("downloads-get"),
toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect),
onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)),
// Add-on sidebar: toggle, open on a specific panel, close, or subscribe
// to state (visibility + which panel is active + the panel list). The
// extension dock in chrome.html renders one button per panel and calls
// openSidebar / closeSidebar accordingly.
toggleSidebar: () => ipcRenderer.invoke("sidebar-toggle"),
openSidebar: (panelId) => ipcRenderer.invoke("sidebar-open", panelId),
closeSidebar: () => ipcRenderer.invoke("sidebar-close"),
sidebarState: () => ipcRenderer.invoke("sidebar-state"),
onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)),
// Extension dock: drag-reorder (key = "p:<panelId>" | "m:<addonId>") and
// the right-click menu (native; main owns the actions).
dockMove: (key, targetKey, place) => ipcRenderer.invoke("dock-move", key, targetKey, place),
dockMenu: (key, rect) => ipcRenderer.invoke("dock-menu", key, rect),
// Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked
// item id to the add-on's "menu-select" handler.
addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId),
// Pop the dock-icon dropdown as a NATIVE menu. Renderer-side DOM popovers
// get clipped by chrome.html's own WebContentsView height (CHROME_H) and
// then covered by the tab view below it — a native Menu.popup escapes that
// layering entirely. Pass the button's viewport-rect so main can anchor.
toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect),
tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, rect),
// Ariadne's Thread menu behind the registry button at the end of the
// address bar (BCDN/ICANN switch, remembered choices, policy, settings).
registryMenuPopup: (rect) => ipcRenderer.invoke("registry-menu-popup", rect),
// Page zoom on the active tab: step +1 / -1 along Chrome's ladder, or reset to 100 %.
zoomStep: (dir) => ipcRenderer.invoke("zoom-step", dir),
zoomReset: () => ipcRenderer.invoke("zoom-reset"),
// Install-as-app chip in the address bar: main decides between the
// install dialog and the open/remove menu (anchored at rect).
webappChip: (rect) => ipcRenderer.invoke("webapp-chip", rect),
// Chrome listens so it can drop the "active" tint when the native menu
// dismisses (Esc, outside click, item picked — main fires all three).
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),
});

View file

@ -1,438 +1,438 @@
// webapps.js — install a site as an app ("PWA install"), the way Chrome and
// Edge offer it. Electron ships Chromium's renderer without the browser-side
// web-app machinery, so `beforeinstallprompt` never fires in an Electron app
// and every site's own "Install our app" chip stays hidden. This module fills
// that gap on the browser side:
//
// · probeTab() reads a page's <link rel="manifest">, checks it describes
// an installable app (a name plus a standalone-style display
// mode, start_url on the page's own origin) and records the
// descriptor on the tab. main.js then shows the address-bar
// chip and fires a synthetic `beforeinstallprompt` so the
// site's own chip appears and works too.
// · install() asks the user (native dialog), stores the app under
// <userData>/webapps/, turns the manifest icon into an .ico
// and writes Start Menu / desktop shortcuts that launch
// Theseus with `--app=<start_url>`.
// · open() the chromeless app window: same session, same BCNR
// resolution, same add-on bridges as a tab, own taskbar
// identity so it can be pinned like any app.
// · launch() what `--app=` resolves to at startup or on a second
// instance.
//
// Windows-first: shortcuts and taskbar identity are Windows APIs; on other
// platforms the app still installs and opens, only without shortcuts.
const { app, BrowserWindow, dialog, nativeImage, shell, session, Menu, clipboard, nativeTheme } = require("electron");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
// Display modes that mean "this wants to be an app, not a page".
const APP_DISPLAYS = new Set(["standalone", "fullscreen", "minimal-ui", "window-controls-overlay", "tabbed"]);
// Runs inside the page: find the manifest link and fetch it the way the
// page itself would (same-origin cookies unless the link opts into CORS
// credentials). Returns { href, text } or null.
const PROBE_SRC = `(async () => { try {
const l = document.querySelector('link[rel~="manifest"]'); if (!l || !l.href) return null;
const r = await fetch(l.href, { credentials: l.crossOrigin === "use-credentials" ? "include" : "same-origin" });
if (!r.ok) return null; return { href: l.href, text: await r.text() };
} catch (e) { return null; } })()`;
// Runs inside the page after a successful probe: the synthetic
// beforeinstallprompt. prompt() asks Theseus through a DOM event that the
// session preload (bcnr-preload.js) relays over IPC; the answer comes back
// as another DOM event, and userChoice resolves with Chrome's shape.
const PROMPT_SRC = `(() => { try {
if (window.__theseusInstallPrompt) return; window.__theseusInstallPrompt = 1;
const e = new Event("beforeinstallprompt", { cancelable: true });
e.platforms = ["windows"];
let done; e.userChoice = new Promise((r) => { done = r; });
document.addEventListener("theseus:webapp-accepted", () => done({ outcome: "accepted", platform: "windows" }), { once: true });
document.addEventListener("theseus:webapp-dismissed", () => done({ outcome: "dismissed", platform: "windows" }), { once: true });
e.prompt = () => { document.dispatchEvent(new Event("theseus:webapp-prompt")); return e.userChoice; };
window.dispatchEvent(e);
} catch (err) {} })()`;
const INSTALLED_SRC = `try { window.dispatchEvent(new Event("appinstalled")); } catch (e) {}`;
let deps = {}; // supplied by main.js — see init()
let apps = []; // installed apps (persisted)
let dir = null, file = null;
const windows = new Map(); // key -> BrowserWindow
let promptOpen = false;
function init(d) {
deps = d;
dir = path.join(app.getPath("userData"), "webapps");
file = path.join(dir, "apps.json");
try { apps = JSON.parse(fs.readFileSync(file, "utf8")); } catch { apps = []; }
if (!Array.isArray(apps)) apps = [];
}
function save() {
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(apps, null, 2)); } catch (e) { console.warn("[webapps] save failed:", e?.message); }
}
const list = () => apps.slice();
const find = (key) => apps.find((a) => a.key === key) || null;
const str = (v) => (typeof v === "string" ? v.trim() : "");
// Pages on BNS names load as bns://host/…; the app is stored under its
// https form (what the user sees, what the shortcut carries). targetUrlFor
// turns it back into bns:// at launch when the resolver says so.
const norm = (u) => String(u || "").replace(/^bns:\/\//i, "https://");
function originOf(u) { try { return new URL(norm(u)).origin; } catch { return ""; } }
const keyFor = (id) => crypto.createHash("sha1").update(id).digest("hex").slice(0, 16);
const aumidFor = (key) => `st.silentmode.theseus.app.${key}`;
// ---- manifest → descriptor ----------------------------------------------
function parseSizes(s) {
let best = 0;
for (const part of String(s || "").split(/\s+/)) {
if (part === "any") return Infinity;
const m = /^(\d+)x(\d+)$/i.exec(part); if (m) best = Math.max(best, Math.min(+m[1], +m[2]));
}
return best;
}
function pickIcon(icons, base) {
if (!Array.isArray(icons)) return null;
let best = null;
for (const ic of icons) {
if (!ic || typeof ic !== "object" || !str(ic.src)) continue;
const type = str(ic.type).toLowerCase();
const svg = type === "image/svg+xml" || /\.svg(\?|$)/i.test(ic.src);
if (svg) continue; // nativeImage can't rasterise SVG; the favicon fallback covers it
if (type && !/^image\/(png|jpeg|jpg|webp|x-icon|vnd\.microsoft\.icon)$/.test(type)) continue;
const purpose = str(ic.purpose).toLowerCase().split(/\s+/).filter(Boolean);
const any = purpose.length === 0 || purpose.includes("any");
const size = parseSizes(ic.sizes);
let href; try { href = new URL(ic.src, base).href; } catch { continue; }
// Rank: "any"-purpose over maskable-only, then the largest size up to
// 512 (bigger is only downscaled), then anything larger.
const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4);
if (!best || rank > best.rank) best = { href, size, rank, any };
}
return best;
}
// Turn a page URL + fetched manifest into an app descriptor, or null when the
// manifest doesn't describe an installable app.
function describe(pageUrl, manifestHref, text) {
let m; try { m = JSON.parse(text); } catch { return null; }
if (!m || typeof m !== "object" || Array.isArray(m)) return null;
const name = str(m.name) || str(m.short_name);
if (!name) return null;
const display = APP_DISPLAYS.has(str(m.display)) ||
(Array.isArray(m.display_override) && m.display_override.some((d) => APP_DISPLAYS.has(str(d))));
if (!display) return null;
let startRaw; try { startRaw = new URL(str(m.start_url) || ".", manifestHref).href; } catch { return null; }
const startUrl = norm(startRaw).replace(/#.*$/, "");
const origin = originOf(pageUrl);
if (!origin || originOf(startUrl) !== origin) return null;
let scope;
try { scope = norm(new URL(str(m.scope) || ".", startRaw).href).replace(/[?#].*$/, ""); } catch { scope = ""; }
if (!scope || originOf(scope) !== origin || !startUrl.startsWith(scope)) scope = startUrl.replace(/[?#].*$/, "").replace(/[^/]*$/, "");
let id; try { id = new URL(str(m.id) || startUrl, startUrl).href; } catch { id = startUrl; }
id = norm(id);
const icon = pickIcon(m.icons, manifestHref);
return {
key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40),
startUrl, scope, origin, host: (() => { try { return new URL(origin).host; } catch { return ""; } })(),
manifestUrl: manifestHref,
iconUrl: icon ? icon.href : null, iconSize: icon ? icon.size : 0,
themeColor: /^#[0-9a-f]{6}$/i.test(str(m.theme_color)) ? str(m.theme_color) : null,
backgroundColor: /^#[0-9a-f]{6}$/i.test(str(m.background_color)) ? str(m.background_color) : null,
};
}
function probeable(url) { return /^(https?|bns):\/\//i.test(String(url || "")); }
// Read the active document's manifest and record the app descriptor (or
// null) on the tab. Returns the descriptor. Fires the synthetic
// beforeinstallprompt when the app isn't installed yet.
async function probeTab(tab) {
const wc = tab?.view?.webContents;
if (!wc || wc.isDestroyed()) return null;
const pageUrl = wc.getURL();
if (!probeable(pageUrl)) { tab.webapp = null; return null; }
let res = null;
try { res = await wc.executeJavaScript(PROBE_SRC, true); } catch {}
if (wc.isDestroyed() || wc.getURL() !== pageUrl) return tab.webapp || null; // navigated away meanwhile
const desc = res && res.href && typeof res.text === "string" ? describe(pageUrl, res.href, res.text) : null;
tab.webapp = desc;
if (desc && !find(desc.key)) { try { await wc.executeJavaScript(PROMPT_SRC, true); } catch {} }
return desc;
}
// Chrome-shaped summary for the toolbar.
function chipState(tab) {
const d = tab?.webapp; if (!d) return null;
return { key: d.key, name: d.name, installed: !!find(d.key) };
}
// ---- icon ------------------------------------------------------------------
// A .ico that simply wraps one PNG (valid since Vista; what most modern .ico
// files are). Windows scales it for every shell size.
function pngToIco(png) {
const w = png.readUInt32BE(16), h = png.readUInt32BE(20);
const hdr = Buffer.alloc(6); hdr.writeUInt16LE(0, 0); hdr.writeUInt16LE(1, 2); hdr.writeUInt16LE(1, 4);
const ent = Buffer.alloc(16);
ent[0] = w >= 256 ? 0 : w; ent[1] = h >= 256 ? 0 : h; ent[2] = 0; ent[3] = 0;
ent.writeUInt16LE(1, 4); ent.writeUInt16LE(32, 6); ent.writeUInt32LE(png.length, 8); ent.writeUInt32LE(22, 12);
return Buffer.concat([hdr, ent, png]);
}
async function fetchBytes(u) {
const r = await session.defaultSession.fetch(u, { cache: "force-cache" });
if (!r.ok) throw new Error("HTTP " + r.status);
return Buffer.from(await r.arrayBuffer());
}
function bundledIcon() {
return app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico");
}
// Manifest icon → PNG (≤ 256 px, square) + ICO on disk. Falls back to the
// page favicon, then to the Theseus icon, so an install never fails on art.
async function writeIcon(desc, faviconUrl, appDir) {
const candidates = [desc.iconUrl, faviconUrl].filter(Boolean);
for (const u of candidates) {
try {
const raw = await fetchBytes(u);
if (raw.length > 4 && raw.readUInt16LE(0) === 0 && raw.readUInt16LE(2) === 1) { // already an .ico
fs.writeFileSync(path.join(appDir, "icon.ico"), raw);
const img = nativeImage.createFromPath(path.join(appDir, "icon.ico"));
if (!img.isEmpty()) fs.writeFileSync(path.join(appDir, "icon.png"), img.toPNG());
return true;
}
let img = nativeImage.createFromBuffer(raw);
if (img.isEmpty()) continue;
const { width, height } = img.getSize();
if (width > 256 || height > 256 || width !== height) {
const s = Math.min(256, Math.max(width, height));
img = img.resize({ width: s, height: s, quality: "best" });
}
const png = img.toPNG();
fs.writeFileSync(path.join(appDir, "icon.png"), png);
fs.writeFileSync(path.join(appDir, "icon.ico"), pngToIco(png));
return true;
} catch {}
}
try { fs.copyFileSync(bundledIcon(), path.join(appDir, "icon.ico")); } catch {}
return false;
}
const icoPath = (entry) => path.join(dir, entry.key, "icon.ico");
const pngPath = (entry) => path.join(dir, entry.key, "icon.png");
function windowIcon(entry) {
for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} }
return bundledIcon();
}
// ---- shortcuts ---------------------------------------------------------------
function launchSpec(entry) {
const target = process.execPath;
// Dev runs are `electron.exe <appdir>`; packaged builds are just the exe.
const args = (app.isPackaged ? "" : `"${app.getAppPath()}" `) + `--app=${entry.startUrl}`;
return { target, args };
}
function shortcutName(entry) {
const base = entry.name.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60) || entry.host;
// Two different apps with the same name: the second gets its host appended.
const clash = apps.some((a) => a.key !== entry.key && a.shortcutBase === base);
return clash ? `${base} (${entry.host})` : base;
}
function writeShortcuts(entry, desktop) {
const paths = [];
if (process.platform !== "win32") return paths;
const { target, args } = launchSpec(entry);
const name = shortcutName(entry);
entry.shortcutBase = name;
const dirs = [path.join(app.getPath("appData"), "Microsoft", "Windows", "Start Menu", "Programs")];
if (desktop) { try { dirs.push(app.getPath("desktop")); } catch {} }
for (const d of dirs) {
const lnk = path.join(d, `${name}.lnk`);
try {
fs.mkdirSync(d, { recursive: true });
const ok = shell.writeShortcutLink(lnk, "create", {
target, args, cwd: path.dirname(target), icon: icoPath(entry), iconIndex: 0,
appUserModelId: aumidFor(entry.key), description: `${entry.name} — runs in Theseus Navigator`,
});
if (ok) paths.push(lnk);
} catch (e) { console.warn("[webapps] shortcut failed:", lnk, e?.message); }
}
return paths;
}
function removeShortcuts(entry) {
for (const p of entry.shortcuts || []) { try { fs.unlinkSync(p); } catch {} }
}
// ---- install / uninstall --------------------------------------------------------
// Asks the user, then installs. `desc` comes from probeTab; `ctx` names the
// tab's webContents (for the appinstalled event) and favicon. Resolves
// { ok, entry } or { ok:false, error } ("cancelled" when the user said no).
async function install(desc, ctx = {}) {
if (!desc || !desc.key) return { ok: false, error: "not installable" };
const have = find(desc.key);
if (have) { open(have); return { ok: true, entry: have, alreadyInstalled: true }; }
if (promptOpen) return { ok: false, error: "another install prompt is open" };
promptOpen = true;
try {
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
let icon; // best effort preview in the dialog
try { const raw = await fetchBytes(desc.iconUrl || ""); const i = nativeImage.createFromBuffer(raw); if (!i.isEmpty()) icon = i.resize({ width: 64, height: 64 }); } catch {}
const { response, checkboxChecked } = await dialog.showMessageBox(parent, {
type: "question", title: "Install app", icon,
message: `Install ${desc.name}?`,
detail: `${desc.host}\n\nIt opens in its own window and gets a Start Menu entry. It keeps running inside Theseus, with your names, add-ons and settings.`,
buttons: ["Install", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
checkboxLabel: process.platform === "win32" ? "Also add a desktop shortcut" : undefined,
checkboxChecked: process.platform === "win32",
});
if (response !== 0) return { ok: false, error: "cancelled" };
const entry = {
key: desc.key, id: desc.id, name: desc.name, shortName: desc.shortName,
startUrl: desc.startUrl, scope: desc.scope, origin: desc.origin, host: desc.host,
manifestUrl: desc.manifestUrl, themeColor: desc.themeColor, backgroundColor: desc.backgroundColor,
installedAt: new Date().toISOString(), shortcuts: [], bounds: null,
};
const appDir = path.join(dir, entry.key);
fs.mkdirSync(appDir, { recursive: true });
entry.hasIcon = await writeIcon(desc, ctx.favicon || null, appDir);
entry.shortcuts = writeShortcuts(entry, !!checkboxChecked);
apps = apps.filter((a) => a.key !== entry.key); apps.push(entry); save();
try { const wc = ctx.wc; if (wc && !wc.isDestroyed()) wc.executeJavaScript(INSTALLED_SRC, true).catch(() => {}); } catch {}
deps.changed && deps.changed();
open(entry);
return { ok: true, entry };
} catch (e) {
console.warn("[webapps] install failed:", e?.message);
return { ok: false, error: e?.message || "install failed" };
} finally { promptOpen = false; }
}
async function uninstall(key, ask = true) {
const entry = find(key); if (!entry) return false;
if (ask) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
const { response } = await dialog.showMessageBox(parent, {
type: "question", title: "Remove app", message: `Remove ${entry.name} from Theseus?`,
detail: "Its window and shortcuts go away. The site itself and your data on it are untouched.",
buttons: ["Remove", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
});
if (response !== 0) return false;
}
const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.close(); } catch {} }
removeShortcuts(entry);
try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {}
apps = apps.filter((a) => a.key !== key); save();
deps.changed && deps.changed();
return true;
}
// ---- the app window ----------------------------------------------------------
function open(entry, urlOverride) {
if (!entry) return null;
const had = windows.get(entry.key);
if (had && !had.isDestroyed()) {
if (urlOverride) had.webContents.loadURL(urlOverride).catch(() => {});
if (had.isMinimized()) had.restore();
had.focus();
return had;
}
const dark = nativeTheme.shouldUseDarkColors;
const b = entry.bounds && typeof entry.bounds === "object" ? entry.bounds : {};
const w = new BrowserWindow({
width: b.width || 1100, height: b.height || 760,
...(Number.isFinite(b.x) && Number.isFinite(b.y) ? { x: b.x, y: b.y } : {}),
title: entry.name, show: false,
backgroundColor: entry.backgroundColor || (dark ? "#0b0e14" : "#ffffff"),
icon: windowIcon(entry),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
windows.set(entry.key, w);
w.setMenuBarVisibility(false);
if (process.platform === "win32") {
// Own taskbar identity: pinning the button pins the app, not Theseus,
// and relaunching from the pin comes back through --app=.
const { target, args } = launchSpec(entry);
try { w.setAppDetails({ appId: aumidFor(entry.key), appIconPath: icoPath(entry), appIconIndex: 0, relaunchCommand: `"${target}" ${args}`, relaunchDisplayName: entry.name }); } catch {}
}
const wc = w.webContents;
deps.prepareContents && deps.prepareContents(wc);
// Page title in the title bar, suffixed with the app name unless the page
// already carries it (most do).
wc.on("page-title-updated", (e, title) => {
e.preventDefault();
const t = String(title || "").trim();
const has = t && (t.toLowerCase().includes(entry.shortName.toLowerCase()) || t.toLowerCase().includes(entry.name.toLowerCase()));
try { w.setTitle(!t ? entry.name : has ? t : `${t} — ${entry.shortName}`); } catch {}
});
// Cross-host navigations inside the window stay BCNR-first, like tabs.
wc.on("will-navigate", (e, u) => {
try {
const p = new URL(u);
if (p.protocol !== "http:" && p.protocol !== "https:") return;
if (!deps.isBnsHost || !deps.isBnsHost(p.hostname)) return;
let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {}
if (cur === p.hostname) return;
e.preventDefault();
deps.targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); });
} catch {}
});
// Popups go to Theseus tabs — one place for tabs, and an app window has
// no tab strip to hold them.
wc.setWindowOpenHandler(({ url }) => {
if (url && url !== "about:blank") deps.openInTab(url);
return { action: "deny" };
});
// No toolbar, so the keyboard carries navigation: Alt+←/→, F5, Ctrl+R.
wc.on("before-input-event", (e, input) => {
if (input.type !== "keyDown") return;
const nav = wc.navigationHistory;
if (input.alt && input.key === "ArrowLeft" && nav.canGoBack()) { nav.goBack(); e.preventDefault(); }
else if (input.alt && input.key === "ArrowRight" && nav.canGoForward()) { nav.goForward(); e.preventDefault(); }
else if (input.key === "F5" || (input.control && (input.key === "r" || input.key === "R"))) { input.shift || (input.control && input.key === "F5") ? wc.reloadIgnoringCache() : wc.reload(); e.preventDefault(); }
else if (input.key === "F12" || (input.control && input.shift && (input.key === "I" || input.key === "i"))) { wc.isDevToolsOpened() ? wc.closeDevTools() : wc.openDevTools({ mode: "bottom" }); e.preventDefault(); }
});
wc.on("context-menu", (_e, p) => {
const items = [];
if (p.linkURL) {
items.push(
{ label: "Open link in Theseus", click: () => deps.openInTab(p.linkURL) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
const nav = wc.navigationHistory;
items.push(
{ label: "Back", enabled: nav.canGoBack(), click: () => nav.goBack() },
{ label: "Forward", enabled: nav.canGoForward(), click: () => nav.goForward() },
{ label: "Reload", click: () => wc.reload() },
{ type: "separator" },
{ label: "Open this page in Theseus", click: () => deps.openInTab(wc.getURL()) },
{ label: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) },
{ type: "separator" },
{ label: `Remove ${entry.shortName} from Theseus…`, click: () => uninstall(entry.key, true) },
);
Menu.buildFromTemplate(items).popup({ window: w });
});
const remember = () => { try { if (!w.isMinimized()) { entry.bounds = w.getNormalBounds(); save(); } } catch {} };
w.on("resize", remember); w.on("move", remember);
w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); });
w.once("ready-to-show", () => { try { w.show(); } catch {} });
Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (urlOverride || entry.startUrl))
.then((t) => wc.loadURL(t || urlOverride || entry.startUrl))
.catch(() => {});
return w;
}
const openWindows = () => [...windows.values()].filter((w) => !w.isDestroyed());
// ---- --app= launches --------------------------------------------------------------
function appUrlFromArgv(argv) {
for (const a of argv || []) {
const m = /^--app=(.+)$/.exec(String(a));
if (m && /^https?:\/\//i.test(m[1])) return m[1];
}
return null;
}
// Open the installed app that owns `url` (start_url match first, then any
// app whose scope contains it). A URL no app owns is returned as `null` so
// the caller can fall back to a normal tab.
function launch(url) {
const u = norm(url);
const entry = apps.find((a) => a.startUrl === u) || apps.find((a) => u.startsWith(a.scope));
if (!entry) return null;
return open(entry, entry.startUrl === u ? undefined : u);
}
module.exports = { init, list, find, probeTab, chipState, install, uninstall, open, openWindows, launch, appUrlFromArgv, describe };
// webapps.js — install a site as an app ("PWA install"), the way Chrome and
// Edge offer it. Electron ships Chromium's renderer without the browser-side
// web-app machinery, so `beforeinstallprompt` never fires in an Electron app
// and every site's own "Install our app" chip stays hidden. This module fills
// that gap on the browser side:
//
// · probeTab() reads a page's <link rel="manifest">, checks it describes
// an installable app (a name plus a standalone-style display
// mode, start_url on the page's own origin) and records the
// descriptor on the tab. main.js then shows the address-bar
// chip and fires a synthetic `beforeinstallprompt` so the
// site's own chip appears and works too.
// · install() asks the user (native dialog), stores the app under
// <userData>/webapps/, turns the manifest icon into an .ico
// and writes Start Menu / desktop shortcuts that launch
// Theseus with `--app=<start_url>`.
// · open() the chromeless app window: same session, same BCNR
// resolution, same add-on bridges as a tab, own taskbar
// identity so it can be pinned like any app.
// · launch() what `--app=` resolves to at startup or on a second
// instance.
//
// Windows-first: shortcuts and taskbar identity are Windows APIs; on other
// platforms the app still installs and opens, only without shortcuts.
const { app, BrowserWindow, dialog, nativeImage, shell, session, Menu, clipboard, nativeTheme } = require("electron");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
// Display modes that mean "this wants to be an app, not a page".
const APP_DISPLAYS = new Set(["standalone", "fullscreen", "minimal-ui", "window-controls-overlay", "tabbed"]);
// Runs inside the page: find the manifest link and fetch it the way the
// page itself would (same-origin cookies unless the link opts into CORS
// credentials). Returns { href, text } or null.
const PROBE_SRC = `(async () => { try {
const l = document.querySelector('link[rel~="manifest"]'); if (!l || !l.href) return null;
const r = await fetch(l.href, { credentials: l.crossOrigin === "use-credentials" ? "include" : "same-origin" });
if (!r.ok) return null; return { href: l.href, text: await r.text() };
} catch (e) { return null; } })()`;
// Runs inside the page after a successful probe: the synthetic
// beforeinstallprompt. prompt() asks Theseus through a DOM event that the
// session preload (bcnr-preload.js) relays over IPC; the answer comes back
// as another DOM event, and userChoice resolves with Chrome's shape.
const PROMPT_SRC = `(() => { try {
if (window.__theseusInstallPrompt) return; window.__theseusInstallPrompt = 1;
const e = new Event("beforeinstallprompt", { cancelable: true });
e.platforms = ["windows"];
let done; e.userChoice = new Promise((r) => { done = r; });
document.addEventListener("theseus:webapp-accepted", () => done({ outcome: "accepted", platform: "windows" }), { once: true });
document.addEventListener("theseus:webapp-dismissed", () => done({ outcome: "dismissed", platform: "windows" }), { once: true });
e.prompt = () => { document.dispatchEvent(new Event("theseus:webapp-prompt")); return e.userChoice; };
window.dispatchEvent(e);
} catch (err) {} })()`;
const INSTALLED_SRC = `try { window.dispatchEvent(new Event("appinstalled")); } catch (e) {}`;
let deps = {}; // supplied by main.js — see init()
let apps = []; // installed apps (persisted)
let dir = null, file = null;
const windows = new Map(); // key -> BrowserWindow
let promptOpen = false;
function init(d) {
deps = d;
dir = path.join(app.getPath("userData"), "webapps");
file = path.join(dir, "apps.json");
try { apps = JSON.parse(fs.readFileSync(file, "utf8")); } catch { apps = []; }
if (!Array.isArray(apps)) apps = [];
}
function save() {
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(apps, null, 2)); } catch (e) { console.warn("[webapps] save failed:", e?.message); }
}
const list = () => apps.slice();
const find = (key) => apps.find((a) => a.key === key) || null;
const str = (v) => (typeof v === "string" ? v.trim() : "");
// Pages on BNS names load as bns://host/…; the app is stored under its
// https form (what the user sees, what the shortcut carries). targetUrlFor
// turns it back into bns:// at launch when the resolver says so.
const norm = (u) => String(u || "").replace(/^bns:\/\//i, "https://");
function originOf(u) { try { return new URL(norm(u)).origin; } catch { return ""; } }
const keyFor = (id) => crypto.createHash("sha1").update(id).digest("hex").slice(0, 16);
const aumidFor = (key) => `st.silentmode.theseus.app.${key}`;
// ---- manifest → descriptor ----------------------------------------------
function parseSizes(s) {
let best = 0;
for (const part of String(s || "").split(/\s+/)) {
if (part === "any") return Infinity;
const m = /^(\d+)x(\d+)$/i.exec(part); if (m) best = Math.max(best, Math.min(+m[1], +m[2]));
}
return best;
}
function pickIcon(icons, base) {
if (!Array.isArray(icons)) return null;
let best = null;
for (const ic of icons) {
if (!ic || typeof ic !== "object" || !str(ic.src)) continue;
const type = str(ic.type).toLowerCase();
const svg = type === "image/svg+xml" || /\.svg(\?|$)/i.test(ic.src);
if (svg) continue; // nativeImage can't rasterise SVG; the favicon fallback covers it
if (type && !/^image\/(png|jpeg|jpg|webp|x-icon|vnd\.microsoft\.icon)$/.test(type)) continue;
const purpose = str(ic.purpose).toLowerCase().split(/\s+/).filter(Boolean);
const any = purpose.length === 0 || purpose.includes("any");
const size = parseSizes(ic.sizes);
let href; try { href = new URL(ic.src, base).href; } catch { continue; }
// Rank: "any"-purpose over maskable-only, then the largest size up to
// 512 (bigger is only downscaled), then anything larger.
const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4);
if (!best || rank > best.rank) best = { href, size, rank, any };
}
return best;
}
// Turn a page URL + fetched manifest into an app descriptor, or null when the
// manifest doesn't describe an installable app.
function describe(pageUrl, manifestHref, text) {
let m; try { m = JSON.parse(text); } catch { return null; }
if (!m || typeof m !== "object" || Array.isArray(m)) return null;
const name = str(m.name) || str(m.short_name);
if (!name) return null;
const display = APP_DISPLAYS.has(str(m.display)) ||
(Array.isArray(m.display_override) && m.display_override.some((d) => APP_DISPLAYS.has(str(d))));
if (!display) return null;
let startRaw; try { startRaw = new URL(str(m.start_url) || ".", manifestHref).href; } catch { return null; }
const startUrl = norm(startRaw).replace(/#.*$/, "");
const origin = originOf(pageUrl);
if (!origin || originOf(startUrl) !== origin) return null;
let scope;
try { scope = norm(new URL(str(m.scope) || ".", startRaw).href).replace(/[?#].*$/, ""); } catch { scope = ""; }
if (!scope || originOf(scope) !== origin || !startUrl.startsWith(scope)) scope = startUrl.replace(/[?#].*$/, "").replace(/[^/]*$/, "");
let id; try { id = new URL(str(m.id) || startUrl, startUrl).href; } catch { id = startUrl; }
id = norm(id);
const icon = pickIcon(m.icons, manifestHref);
return {
key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40),
startUrl, scope, origin, host: (() => { try { return new URL(origin).host; } catch { return ""; } })(),
manifestUrl: manifestHref,
iconUrl: icon ? icon.href : null, iconSize: icon ? icon.size : 0,
themeColor: /^#[0-9a-f]{6}$/i.test(str(m.theme_color)) ? str(m.theme_color) : null,
backgroundColor: /^#[0-9a-f]{6}$/i.test(str(m.background_color)) ? str(m.background_color) : null,
};
}
function probeable(url) { return /^(https?|bns):\/\//i.test(String(url || "")); }
// Read the active document's manifest and record the app descriptor (or
// null) on the tab. Returns the descriptor. Fires the synthetic
// beforeinstallprompt when the app isn't installed yet.
async function probeTab(tab) {
const wc = tab?.view?.webContents;
if (!wc || wc.isDestroyed()) return null;
const pageUrl = wc.getURL();
if (!probeable(pageUrl)) { tab.webapp = null; return null; }
let res = null;
try { res = await wc.executeJavaScript(PROBE_SRC, true); } catch {}
if (wc.isDestroyed() || wc.getURL() !== pageUrl) return tab.webapp || null; // navigated away meanwhile
const desc = res && res.href && typeof res.text === "string" ? describe(pageUrl, res.href, res.text) : null;
tab.webapp = desc;
if (desc && !find(desc.key)) { try { await wc.executeJavaScript(PROMPT_SRC, true); } catch {} }
return desc;
}
// Chrome-shaped summary for the toolbar.
function chipState(tab) {
const d = tab?.webapp; if (!d) return null;
return { key: d.key, name: d.name, installed: !!find(d.key) };
}
// ---- icon ------------------------------------------------------------------
// A .ico that simply wraps one PNG (valid since Vista; what most modern .ico
// files are). Windows scales it for every shell size.
function pngToIco(png) {
const w = png.readUInt32BE(16), h = png.readUInt32BE(20);
const hdr = Buffer.alloc(6); hdr.writeUInt16LE(0, 0); hdr.writeUInt16LE(1, 2); hdr.writeUInt16LE(1, 4);
const ent = Buffer.alloc(16);
ent[0] = w >= 256 ? 0 : w; ent[1] = h >= 256 ? 0 : h; ent[2] = 0; ent[3] = 0;
ent.writeUInt16LE(1, 4); ent.writeUInt16LE(32, 6); ent.writeUInt32LE(png.length, 8); ent.writeUInt32LE(22, 12);
return Buffer.concat([hdr, ent, png]);
}
async function fetchBytes(u) {
const r = await session.defaultSession.fetch(u, { cache: "force-cache" });
if (!r.ok) throw new Error("HTTP " + r.status);
return Buffer.from(await r.arrayBuffer());
}
function bundledIcon() {
return app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico");
}
// Manifest icon → PNG (≤ 256 px, square) + ICO on disk. Falls back to the
// page favicon, then to the Theseus icon, so an install never fails on art.
async function writeIcon(desc, faviconUrl, appDir) {
const candidates = [desc.iconUrl, faviconUrl].filter(Boolean);
for (const u of candidates) {
try {
const raw = await fetchBytes(u);
if (raw.length > 4 && raw.readUInt16LE(0) === 0 && raw.readUInt16LE(2) === 1) { // already an .ico
fs.writeFileSync(path.join(appDir, "icon.ico"), raw);
const img = nativeImage.createFromPath(path.join(appDir, "icon.ico"));
if (!img.isEmpty()) fs.writeFileSync(path.join(appDir, "icon.png"), img.toPNG());
return true;
}
let img = nativeImage.createFromBuffer(raw);
if (img.isEmpty()) continue;
const { width, height } = img.getSize();
if (width > 256 || height > 256 || width !== height) {
const s = Math.min(256, Math.max(width, height));
img = img.resize({ width: s, height: s, quality: "best" });
}
const png = img.toPNG();
fs.writeFileSync(path.join(appDir, "icon.png"), png);
fs.writeFileSync(path.join(appDir, "icon.ico"), pngToIco(png));
return true;
} catch {}
}
try { fs.copyFileSync(bundledIcon(), path.join(appDir, "icon.ico")); } catch {}
return false;
}
const icoPath = (entry) => path.join(dir, entry.key, "icon.ico");
const pngPath = (entry) => path.join(dir, entry.key, "icon.png");
function windowIcon(entry) {
for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} }
return bundledIcon();
}
// ---- shortcuts ---------------------------------------------------------------
function launchSpec(entry) {
const target = process.execPath;
// Dev runs are `electron.exe <appdir>`; packaged builds are just the exe.
const args = (app.isPackaged ? "" : `"${app.getAppPath()}" `) + `--app=${entry.startUrl}`;
return { target, args };
}
function shortcutName(entry) {
const base = entry.name.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60) || entry.host;
// Two different apps with the same name: the second gets its host appended.
const clash = apps.some((a) => a.key !== entry.key && a.shortcutBase === base);
return clash ? `${base} (${entry.host})` : base;
}
function writeShortcuts(entry, desktop) {
const paths = [];
if (process.platform !== "win32") return paths;
const { target, args } = launchSpec(entry);
const name = shortcutName(entry);
entry.shortcutBase = name;
const dirs = [path.join(app.getPath("appData"), "Microsoft", "Windows", "Start Menu", "Programs")];
if (desktop) { try { dirs.push(app.getPath("desktop")); } catch {} }
for (const d of dirs) {
const lnk = path.join(d, `${name}.lnk`);
try {
fs.mkdirSync(d, { recursive: true });
const ok = shell.writeShortcutLink(lnk, "create", {
target, args, cwd: path.dirname(target), icon: icoPath(entry), iconIndex: 0,
appUserModelId: aumidFor(entry.key), description: `${entry.name} — runs in Theseus Navigator`,
});
if (ok) paths.push(lnk);
} catch (e) { console.warn("[webapps] shortcut failed:", lnk, e?.message); }
}
return paths;
}
function removeShortcuts(entry) {
for (const p of entry.shortcuts || []) { try { fs.unlinkSync(p); } catch {} }
}
// ---- install / uninstall --------------------------------------------------------
// Asks the user, then installs. `desc` comes from probeTab; `ctx` names the
// tab's webContents (for the appinstalled event) and favicon. Resolves
// { ok, entry } or { ok:false, error } ("cancelled" when the user said no).
async function install(desc, ctx = {}) {
if (!desc || !desc.key) return { ok: false, error: "not installable" };
const have = find(desc.key);
if (have) { open(have); return { ok: true, entry: have, alreadyInstalled: true }; }
if (promptOpen) return { ok: false, error: "another install prompt is open" };
promptOpen = true;
try {
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
let icon; // best effort preview in the dialog
try { const raw = await fetchBytes(desc.iconUrl || ""); const i = nativeImage.createFromBuffer(raw); if (!i.isEmpty()) icon = i.resize({ width: 64, height: 64 }); } catch {}
const { response, checkboxChecked } = await dialog.showMessageBox(parent, {
type: "question", title: "Install app", icon,
message: `Install ${desc.name}?`,
detail: `${desc.host}\n\nIt opens in its own window and gets a Start Menu entry. It keeps running inside Theseus, with your names, add-ons and settings.`,
buttons: ["Install", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
checkboxLabel: process.platform === "win32" ? "Also add a desktop shortcut" : undefined,
checkboxChecked: process.platform === "win32",
});
if (response !== 0) return { ok: false, error: "cancelled" };
const entry = {
key: desc.key, id: desc.id, name: desc.name, shortName: desc.shortName,
startUrl: desc.startUrl, scope: desc.scope, origin: desc.origin, host: desc.host,
manifestUrl: desc.manifestUrl, themeColor: desc.themeColor, backgroundColor: desc.backgroundColor,
installedAt: new Date().toISOString(), shortcuts: [], bounds: null,
};
const appDir = path.join(dir, entry.key);
fs.mkdirSync(appDir, { recursive: true });
entry.hasIcon = await writeIcon(desc, ctx.favicon || null, appDir);
entry.shortcuts = writeShortcuts(entry, !!checkboxChecked);
apps = apps.filter((a) => a.key !== entry.key); apps.push(entry); save();
try { const wc = ctx.wc; if (wc && !wc.isDestroyed()) wc.executeJavaScript(INSTALLED_SRC, true).catch(() => {}); } catch {}
deps.changed && deps.changed();
open(entry);
return { ok: true, entry };
} catch (e) {
console.warn("[webapps] install failed:", e?.message);
return { ok: false, error: e?.message || "install failed" };
} finally { promptOpen = false; }
}
async function uninstall(key, ask = true) {
const entry = find(key); if (!entry) return false;
if (ask) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
const { response } = await dialog.showMessageBox(parent, {
type: "question", title: "Remove app", message: `Remove ${entry.name} from Theseus?`,
detail: "Its window and shortcuts go away. The site itself and your data on it are untouched.",
buttons: ["Remove", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
});
if (response !== 0) return false;
}
const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.close(); } catch {} }
removeShortcuts(entry);
try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {}
apps = apps.filter((a) => a.key !== key); save();
deps.changed && deps.changed();
return true;
}
// ---- the app window ----------------------------------------------------------
function open(entry, urlOverride) {
if (!entry) return null;
const had = windows.get(entry.key);
if (had && !had.isDestroyed()) {
if (urlOverride) had.webContents.loadURL(urlOverride).catch(() => {});
if (had.isMinimized()) had.restore();
had.focus();
return had;
}
const dark = nativeTheme.shouldUseDarkColors;
const b = entry.bounds && typeof entry.bounds === "object" ? entry.bounds : {};
const w = new BrowserWindow({
width: b.width || 1100, height: b.height || 760,
...(Number.isFinite(b.x) && Number.isFinite(b.y) ? { x: b.x, y: b.y } : {}),
title: entry.name, show: false,
backgroundColor: entry.backgroundColor || (dark ? "#0b0e14" : "#ffffff"),
icon: windowIcon(entry),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
windows.set(entry.key, w);
w.setMenuBarVisibility(false);
if (process.platform === "win32") {
// Own taskbar identity: pinning the button pins the app, not Theseus,
// and relaunching from the pin comes back through --app=.
const { target, args } = launchSpec(entry);
try { w.setAppDetails({ appId: aumidFor(entry.key), appIconPath: icoPath(entry), appIconIndex: 0, relaunchCommand: `"${target}" ${args}`, relaunchDisplayName: entry.name }); } catch {}
}
const wc = w.webContents;
deps.prepareContents && deps.prepareContents(wc);
// Page title in the title bar, suffixed with the app name unless the page
// already carries it (most do).
wc.on("page-title-updated", (e, title) => {
e.preventDefault();
const t = String(title || "").trim();
const has = t && (t.toLowerCase().includes(entry.shortName.toLowerCase()) || t.toLowerCase().includes(entry.name.toLowerCase()));
try { w.setTitle(!t ? entry.name : has ? t : `${t} — ${entry.shortName}`); } catch {}
});
// Cross-host navigations inside the window stay BCNR-first, like tabs.
wc.on("will-navigate", (e, u) => {
try {
const p = new URL(u);
if (p.protocol !== "http:" && p.protocol !== "https:") return;
if (!deps.isBnsHost || !deps.isBnsHost(p.hostname)) return;
let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {}
if (cur === p.hostname) return;
e.preventDefault();
deps.targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); });
} catch {}
});
// Popups go to Theseus tabs — one place for tabs, and an app window has
// no tab strip to hold them.
wc.setWindowOpenHandler(({ url }) => {
if (url && url !== "about:blank") deps.openInTab(url);
return { action: "deny" };
});
// No toolbar, so the keyboard carries navigation: Alt+←/→, F5, Ctrl+R.
wc.on("before-input-event", (e, input) => {
if (input.type !== "keyDown") return;
const nav = wc.navigationHistory;
if (input.alt && input.key === "ArrowLeft" && nav.canGoBack()) { nav.goBack(); e.preventDefault(); }
else if (input.alt && input.key === "ArrowRight" && nav.canGoForward()) { nav.goForward(); e.preventDefault(); }
else if (input.key === "F5" || (input.control && (input.key === "r" || input.key === "R"))) { input.shift || (input.control && input.key === "F5") ? wc.reloadIgnoringCache() : wc.reload(); e.preventDefault(); }
else if (input.key === "F12" || (input.control && input.shift && (input.key === "I" || input.key === "i"))) { wc.isDevToolsOpened() ? wc.closeDevTools() : wc.openDevTools({ mode: "bottom" }); e.preventDefault(); }
});
wc.on("context-menu", (_e, p) => {
const items = [];
if (p.linkURL) {
items.push(
{ label: "Open link in Theseus", click: () => deps.openInTab(p.linkURL) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
const nav = wc.navigationHistory;
items.push(
{ label: "Back", enabled: nav.canGoBack(), click: () => nav.goBack() },
{ label: "Forward", enabled: nav.canGoForward(), click: () => nav.goForward() },
{ label: "Reload", click: () => wc.reload() },
{ type: "separator" },
{ label: "Open this page in Theseus", click: () => deps.openInTab(wc.getURL()) },
{ label: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) },
{ type: "separator" },
{ label: `Remove ${entry.shortName} from Theseus…`, click: () => uninstall(entry.key, true) },
);
Menu.buildFromTemplate(items).popup({ window: w });
});
const remember = () => { try { if (!w.isMinimized()) { entry.bounds = w.getNormalBounds(); save(); } } catch {} };
w.on("resize", remember); w.on("move", remember);
w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); });
w.once("ready-to-show", () => { try { w.show(); } catch {} });
Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (urlOverride || entry.startUrl))
.then((t) => wc.loadURL(t || urlOverride || entry.startUrl))
.catch(() => {});
return w;
}
const openWindows = () => [...windows.values()].filter((w) => !w.isDestroyed());
// ---- --app= launches --------------------------------------------------------------
function appUrlFromArgv(argv) {
for (const a of argv || []) {
const m = /^--app=(.+)$/.exec(String(a));
if (m && /^https?:\/\//i.test(m[1])) return m[1];
}
return null;
}
// Open the installed app that owns `url` (start_url match first, then any
// app whose scope contains it). A URL no app owns is returned as `null` so
// the caller can fall back to a normal tab.
function launch(url) {
const u = norm(url);
const entry = apps.find((a) => a.startUrl === u) || apps.find((a) => u.startsWith(a.scope));
if (!entry) return null;
return open(entry, entry.startUrl === u ? undefined : u);
}
module.exports = { init, list, find, probeTab, chipState, install, uninstall, open, openWindows, launch, appUrlFromArgv, describe };