Merge release/0.3.56 into release/0.3.57

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

View file

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

View file

@ -91,6 +91,7 @@ async function loadDeps(api) {
// the primary BCH adapter but a single fixed address per wallet. // the primary BCH adapter but a single fixed address per wallet.
const importedBchAdapter = require("./lib/chain-bch-imported.js")({ const importedBchAdapter = require("./lib/chain-bch-imported.js")({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx, 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- // Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum-
// based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC // 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 // chain-native private key). Used by the importWallet handler to compute
// the address client-side before wallet-imports.enc stores the material. // the address client-side before wallet-imports.enc stores the material.
const derive = require("./lib/import-derive.js")({ 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, cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory,
ecpairFactory: ECPairFactory, ecc, bip39, dgbCore, ecpairFactory: ECPairFactory, ecc, bip39, dgbCore,
}); });
@ -505,8 +506,43 @@ async function mountWallet(entry) {
...commonOpts, ...commonOpts,
cashaddr: entry.importedCashaddr || entry.importedAddress, cashaddr: entry.importedCashaddr || entry.importedAddress,
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined, servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
importId: entry.importId,
}); });
adapter.schedulePoll(20_000); 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") { } else if (entry.chain === "btc" || entry.chain === "dgb") {
adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({ adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({
...commonOpts, chain: entry.chain, address: entry.importedAddress, ...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), rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined),
}); });
adapter.schedulePoll(20_000); 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 { } else {
throw new Error(`no imported adapter for chain "${entry.chain}"`); throw new Error(`no imported adapter for chain "${entry.chain}"`);
} }
@ -647,6 +709,7 @@ function unmountWallet(walletId) {
const rt = ctx.runtimes.get(walletId); const rt = ctx.runtimes.get(walletId);
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} } if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} } if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} }
wcIneligible.delete(walletId);
ctx.runtimes.delete(walletId); ctx.runtimes.delete(walletId);
} }
@ -721,6 +784,18 @@ function overallPhase() {
return "ready"; 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) { function walletSummary(w) {
const meta = chainMeta(w.chain, w.network); const meta = chainMeta(w.chain, w.network);
const rt = ctx.runtimes.get(w.id); 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. // stay null so the picker knows whether to render the mono path line.
accountPath: w.accountPath || snap?.accountPath || null, accountPath: w.accountPath || snap?.accountPath || null,
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 }, 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", phase: rt?.phase || "locked",
error: rt?.error || null, 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}`; spec.wif = `aegis-privb58:${raw}`;
} else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); } } else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); }
spec.cashaddr = address; // storage-key reuse — see BTC/DGB comment above 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 { } else {
throw new Error(`import not supported for chain "${chain}"`); 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(); }); 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) => { api.onMessage("nextAddress", (_p, m) => {
fromPanel(m); fromPanel(m);
const rt = requireSelected(); const rt = requireSelected();
@ -1273,6 +1400,131 @@ function registerPanelMessages(api) {
return rt.adapter.signAndBroadcast(plan); 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) => { api.onMessage("recovery", async (p, m) => {
fromPanel(m); fromPanel(m);
const id = String(p && p.id || selectedWalletId()); const id = String(p && p.id || selectedWalletId());
@ -1333,6 +1585,80 @@ function registerPanelMessages(api) {
return fullState(); return fullState();
}); });
// Scan the open dapp tab for a wiz:// pairing code, for dapps that render
// one but haven't adopted window.wizardconnect. Strictly user-initiated —
// it runs when someone presses "Scan page", never on a timer and never in
// the background. The host does the matching and returns only the URIs, so
// Aegis never receives page content.
api.onMessage("wcScanPage", async (_p, m) => {
fromPanel(m);
if (typeof api.scanActiveTabForUris !== "function") {
throw new Error("This Theseus build can't scan pages yet — update Theseus, or paste the wiz:// code manually.");
}
const { origin, uris } = await api.scanActiveTabForUris({ scheme: "wiz", limit: 10 });
return { origin: origin || null, uris: Array.isArray(uris) ? uris : [] };
});
// ---- WizardConnect from the page (0.8.8) --------------------------------
//
// 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 // Reorder wallets by an explicit ID list. Silently drops IDs that are
// not in the current wallet set (removed since the panel last read); // 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 // 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); 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) ----------------------------------- // ---- Tron bridge (tronWeb / tronLink) -----------------------------------
api.onMessage("trx.requestAccounts", async (_p, m) => { api.onMessage("trx.requestAccounts", async (_p, m) => {
@ -2251,6 +2623,13 @@ module.exports = {
log: (...a) => api.log("prices", ...a), log: (...a) => api.log("prices", ...a),
onChange: () => emitState(), 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 wc: null, // WizardConnect manager, initialised when deps load
}; };
migrateLegacyStorage(api); 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; 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) { function encode(prefix, type, hash) {
if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported"); if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported");
const versionByte = (type << 3) | 0; // size bits 000 = 160 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)); 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(""); 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). // Accepts "prefix:payload" or a bare payload (assumes defaultPrefix).
function decode(address, defaultPrefix = "bitcoincash") { function decode(address, defaultPrefix = "bitcoincash") {
@ -88,15 +99,30 @@ function decodeLegacy(address, sha256) {
return { prefix: "bitcoincash", type, hash: body.slice(1) }; return { prefix: "bitcoincash", type, hash: body.slice(1) };
} }
// Anything a user might paste -> { type, hash, cashaddr }. Rejects other // Anything a user might paste -> { type, hash, cashaddr, tokenAware }.
// prefixes so a chipnet address can never be paid on mainnet by accident. // 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") { function parseAny(input, sha256, prefix = "bitcoincash") {
const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:"); const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:");
if (!s) throw new Error("empty address"); 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); 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.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"); if (r.type < 0 || r.type > 3) throw new Error(`unsupported address type ${r.type}`);
return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) }; 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 // Imported BCH wallet — single-address, key material lives in Theseus's
// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's // wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's
// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but // public shape (snapshot, refresh, plan, signAndBroadcast, signMessage,
// does NOT go through vault.derive + HKDF: derivation is direct from the // dispose) but does NOT go through vault.derive + HKDF: derivation is
// seed+path or WIF that the user imported. // direct from the seed+path or WIF that the user imported.
// //
// M.1a scope: read-only (balance + history over Electrum). planSend/send // 0.6.36+: spend path enabled. plan() builds a P2PKH tx off the wallet's
// throw with a clear message until M.1b lands the sign path. // 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 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 p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
const scripthashOf = (script) => toHex(sha256(script).slice().reverse()); const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
@ -19,9 +23,9 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
const IMPORTED_BCH_NETWORKS = { const IMPORTED_BCH_NETWORKS = {
mainnet: { mainnet: {
id: "mainnet", label: "Mainnet", prefix: "bitcoincash", id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80,
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", explorerTx: "https://bchexplorer.cash/tx/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/", explorerAddr: "https://bchexplorer.cash/address/",
defaultServers: [ defaultServers: [
"wss://bch.imaginary.cash:50004", "wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004", "wss://cashnode.bch.ninja:50004",
@ -30,7 +34,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
], ],
}, },
chipnet: { chipnet: {
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef,
explorerTx: "https://chipnet.imaginary.cash/tx/", explorerTx: "https://chipnet.imaginary.cash/tx/",
explorerAddr: "https://chipnet.imaginary.cash/address/", explorerAddr: "https://chipnet.imaginary.cash/address/",
defaultServers: [ 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) { function h160OfCashaddr(addr) {
const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, ""); const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, "");
const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean); const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean);
@ -51,12 +52,56 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
return hash; 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 { 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]; const net = IMPORTED_BCH_NETWORKS[network];
if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`); if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`);
if (!address) throw new Error("chain-bch-imported: cashaddr required"); if (!address) throw new Error("chain-bch-imported: cashaddr required");
this.walletId = walletId; 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.chain = "bch";
this.network = net.id; this.network = net.id;
this._net = net; this._net = net;
@ -73,6 +118,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
this._state = { this._state = {
balance: { confirmed: 0, unconfirmed: 0 }, balance: { confirmed: 0, unconfirmed: 0 },
history: [], history: [],
utxos: [],
height: 0, height: 0,
scanning: false, scanning: false,
error: null, error: null,
@ -107,6 +153,10 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
server: this._client.url || null, server: this._client.url || null,
servers: this._servers, servers: this._servers,
imported: true, 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, explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr, explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet, faucet: this._net.faucet,
@ -116,11 +166,15 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
async refresh(full) { async refresh(full) {
this._state.scanning = true; this._emit(); this._state.scanning = true; this._emit();
try { try {
// Balance for this single scripthash. const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]);
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) }; 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) { 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) => ({ this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0, 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 }; } 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."); } // 0.6.36 spend path. Builds an unsigned P2PKH plan against the wallet's
signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); } // own UTXO set. Signing happens in signAndBroadcast, which fetches the
signMessage() { throw new Error("Imported wallets are read-only in this build."); } // 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)." }; } 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", label: "Mainnet",
prefix: "bitcoincash", prefix: "bitcoincash",
defaultAccountPath: "m/44'/145'/0'", defaultAccountPath: "m/44'/145'/0'",
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", explorerTx: "https://bchexplorer.cash/tx/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/", explorerAddr: "https://bchexplorer.cash/address/",
defaultServers: [ defaultServers: [
"wss://bch.imaginary.cash:50004", "wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004", "wss://cashnode.bch.ninja:50004",
@ -105,6 +105,9 @@ module.exports = function makeBchAdapter({
addressIndex: w.addressIndex, addressIndex: w.addressIndex,
addressPath: w.addressPath, addressPath: w.addressPath,
balance: w.balance, 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, height: w.height,
history: w.history, history: w.history,
scanning: w.scanning, scanning: w.scanning,
@ -126,7 +129,8 @@ module.exports = function makeBchAdapter({
const targets = Array.isArray(spec.outputs) && spec.outputs.length const targets = Array.isArray(spec.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value })) ? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec.to, value: spec.amount ?? spec.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); } async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); }
// 65-byte BIP-137 recoverable signature — the format Electron Cash and // 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 // Generic single-address read-only imported adapter for account-model
// chains. One config-driven runtime handles ETH-family, Tron, and Solana // chains. One config-driven runtime handles ETH-family, Tron, and Solana
// balance polling — every chain differs only in the RPC verb and the // balance polling — every chain differs only in the RPC verb and the
// JSON path to the balance number. // JSON path to the balance number.
// //
// The adapter mirrors the public shape every Aegis chain runtime exposes // The adapter mirrors the public shape every Aegis chain runtime exposes
// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet // (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet
// stays chain-agnostic. planSend/send throw a "read-only" error until // stays chain-agnostic. planSend/send throw a "read-only" error until
// M.1b delivers the sign path per chain. // M.1b delivers the sign path per chain.
module.exports = function makeGenericImportedAdapter() { module.exports = function makeGenericImportedAdapter() {
const CHAIN_CFGS = { // base58check T… -> 41-prefixed hex, for comparing against the raw
eth: { // owner_address/to_address fields the /v1 tx feed returns.
ticker: "ETH", decimals: 18, const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
networks: { function tronAddrToHex(b58) {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" }, try {
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/" }, let n = 0n;
}, for (const ch of String(b58)) {
// JSON-RPC eth_getBalance → hex-string wei. const i = B58.indexOf(ch);
async fetchBalance({ rpc, address }) { if (i < 0) return "";
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, n = n * 58n + BigInt(i);
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) }); }
const j = await r.json(); let hex = n.toString(16);
const hex = String(j?.result || "0x0").replace(/^0x/, ""); if (hex.length % 2) hex = "0" + hex;
return BigInt("0x" + hex).toString(); // 25 bytes = 21 payload + 4 checksum; drop the checksum.
}, return hex.padStart(50, "0").slice(0, 42);
}, } catch { return ""; }
trx: { }
ticker: "TRX", decimals: 6,
networks: { // Airdrop spam is the norm on public addresses — a real test address came
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" }, // back with 855 ERC-20s and 3078 SPL mints. Rendering all of those in a
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" }, // sidebar is useless, so every fetchTokens caps its list. Sorting puts
}, // named/known tokens first, so the cap drops spam before it drops
// Tron HTTP API returns account.balance in SUN (10^-6 TRX). // anything the user recognises.
async fetchBalance({ rpc, address }) { const TOKEN_CAP = 50;
const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST",
headers: { "content-type": "application/json" }, // Token names are attacker-controlled. Scam mints ship symbols that are
body: JSON.stringify({ address, visible: true }) }); // blank, pure whitespace, zero-width characters, or carry bidi overrides
const j = await r.json(); // to make one string render as another. Strip the invisible classes, cap
return String(j?.balance || 0); // 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.
sol: { // Ranges are listed numerically rather than as a regex character class on
ticker: "SOL", decimals: 9, // purpose: a literal class would need these very characters in the source,
networks: { // where they are invisible to a reviewer and easy for an editor or a patch
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/" }, // tool to mangle.
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/" }, const INVISIBLE_RANGES = [
}, [0x0000, 0x001f], [0x007f, 0x009f], // C0 / C1 controls
// Solana JSON-RPC getBalance returns lamports as a number. [0x200b, 0x200f], // zero-width space..RTL mark
async fetchBalance({ rpc, address }) { [0x202a, 0x202e], // bidi embedding / override
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, [0x2060, 0x206f], // word joiner, invisible operators
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) }); [0xfeff, 0xfeff], // BOM / zero-width no-break space
const j = await r.json(); ];
return String(j?.result?.value || 0); 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;
class GenericImportedWallet { out += ch;
constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) { }
const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`); return out.replace(/\s+/g, " ").trim().slice(0, 32);
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; const CHAIN_CFGS = {
this.network = network; eth: {
this._cfg = cfg; ticker: "ETH", decimals: 18,
this._net = { ...net, rpc: rpcUrl || net.rpc }; networks: {
this.log = log; // `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints
this.onChange = onChange; // above serve balances but have no history or token concept at all —
this._address = address; // that's why imported ETH wallets showed a balance and nothing else.
this._state = { // Etherscan V2 would need an API key; Blockscout does not.
balance: { confirmed: "0", unconfirmed: "0" }, // publicnode, not llamarpc: llamarpc was answering 525 with an HTML
history: [], // error page, which surfaced as a JSON parse error and a 0 balance.
scanning: false, 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/" },
error: null, 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/" },
}; },
this._pollTimer = null; // JSON-RPC eth_getBalance → hex-string wei.
} async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ } body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) });
schedulePoll(ms) { const j = await r.json();
clearTimeout(this._pollTimer); const hex = String(j?.result || "0x0").replace(/^0x/, "");
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms); return BigInt("0x" + hex).toString();
} },
async fetchHistory({ address, net }) {
_emit() { try { this.onChange(); } catch {} } if (!net?.indexer) return null;
const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/transactions`, { headers: { accept: "application/json" } });
snapshot() { if (!r.ok) throw new Error(`Blockscout history HTTP ${r.status}`);
return { const j = await r.json();
chain: this.chain, network: this.network, const items = Array.isArray(j?.items) ? j.items : [];
ticker: this._cfg.ticker, decimals: this._cfg.decimals, const me = String(address).toLowerCase();
address: this._address, return items.slice(0, 25).map((t) => {
addressIndex: 0, const from = String(t.from?.hash || "").toLowerCase();
addressPath: null, const wei = BigInt(String(t.value || "0"));
balance: this._state.balance, const outgoing = from === me;
history: this._state.history, // A mempool tx comes back as {result:"pending", status:null,
scanning: this._state.scanning, // timestamp:null}. Reading that as `status !== "ok" → failed`
error: this._state.error, // showed pending sends as failures, which is the one thing a
server: this._net.rpc, // wallet must never get wrong.
rpcUrl: this._net.rpc, const pending = t.result === "pending" || t.status == null;
imported: true, return {
explorerAddr: this._net.explorerAddr, txid: t.hash,
explorerTx: this._net.explorerTx, time: Math.floor(new Date(t.timestamp || 0).getTime() / 1000) || 0,
explorerSuffix: this._net.explorerSuffix || "", confirmations: Number(t.confirmations) || 0,
faucet: this._net.faucet || null, 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",
async refresh() { };
this._state.scanning = true; this._emit(); }).filter((t) => t.txid);
try { },
const confirmed = await this._cfg.fetchBalance({ rpc: this._net.rpc, address: this._address }); async fetchTokens({ address, net }) {
this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" }; if (!net?.indexer) return null;
this._state.error = null; const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/token-balances`, { headers: { accept: "application/json" } });
} catch (e) { if (!r.ok) throw new Error(`Blockscout tokens HTTP ${r.status}`);
this._state.error = e?.message || String(e); const j = await r.json();
} finally { const list = Array.isArray(j) ? j : [];
this._state.scanning = false; return list.map((e) => {
this._emit(); const t = e?.token || {};
} const symbol = cleanTokenText(t.symbol);
} return {
mint: t.address_hash || t.address || "",
nextAddress() { return { address: this._address, index: 0 }; } symbol: symbol || "?",
current() { return { address: this._address, index: 0, branch: 0, path: null }; } name: cleanTokenText(t.name),
decimals: Number(t.decimals) || 0,
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); } known: !!symbol,
signAndBroadcast() { throw new Error("read-only"); } balance: String(e.value ?? "0"),
signMessage() { throw new Error("read-only"); } };
}).filter((t) => t.mint && t.balance !== "0")
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; } .sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
dispose() { clearTimeout(this._pollTimer); } },
} },
trx: {
return { GenericImportedWallet, CHAIN_CFGS }; 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 keysLib = require("./sia/keys.js")({ sia });
const walletd = require("./sia/walletd.js")({ log: () => {} }); const walletd = require("./sia/walletd.js")({ log: () => {} });
const walletFactory = require("./sia/wallet.js"); const walletFactory = require("./sia/wallet.js");
const siascanLib = require("./sia/siascan.js")({ log: () => {} });
function scopedStorage(storage, keyPrefix) { function scopedStorage(storage, keyPrefix) {
const k = (key) => keyPrefix + key; const k = (key) => keyPrefix + key;
@ -32,7 +33,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
class SiaWallet { class SiaWallet {
constructor(root32, { constructor(root32, {
walletId, storage, log = () => {}, onChange = () => {}, walletId, storage, log = () => {}, onChange = () => {},
walletdUrl = "", walletdUrl = "", siascanUrl = "",
} = {}) { } = {}) {
if (!walletId) throw new Error("chain-sia: walletId required"); if (!walletId) throw new Error("chain-sia: walletId required");
this.walletId = walletId; this.walletId = walletId;
@ -44,9 +45,67 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
this._root = new Uint8Array(root32); this._root = new Uint8Array(root32);
this._keys = new keysLib.WalletKeys(root32); this._keys = new keysLib.WalletKeys(root32);
this._walletdUrl = String(walletdUrl || "").trim(); this._walletdUrl = String(walletdUrl || "").trim();
this._siascanUrl = String(siascanUrl || "").trim() || siascanLib.DEFAULT_BASE;
this._client = null; this._client = null;
this._wallet = 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(); 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() { _build() {
@ -65,21 +124,44 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
const v = String(url || "").trim(); const v = String(url || "").trim();
if (v === this._walletdUrl) return; if (v === this._walletdUrl) return;
this._walletdUrl = v; 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 // The panel treats Sia amounts as decimal strings of hastings; the
// display layer picks how many SC-precision digits to show. // display layer picks how many SC-precision digits to show.
snapshot() { snapshot() {
const w = this._wallet && this._wallet.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 = { const base = {
chain: "sc", network: "mainnet", ticker: "SC", decimals: 24, 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" }, balance: { confirmed: "0", unconfirmed: "0" },
height: 0, history: [], scanning: false, error: null, 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, 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, explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null,
}; };
if (w) { if (w) {
@ -100,21 +182,43 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
})); }));
base.scanning = w.scanning; base.scanning = w.scanning;
base.error = w.error; 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; return base;
} }
async refresh(full) { async refresh(full) {
if (!this._wallet) return; if (this._wallet) return this._wallet.refresh(!!full);
return this._wallet.refresh(!!full); // Siascan path: fetch now, don't wait for the poll tick.
return this._refreshFromSiascan();
} }
nextAddress() { nextAddress() {
if (!this._wallet) throw new Error("no walletd URL configured"); if (this._wallet) return this._wallet.nextUnusedAddress();
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() { current() {
if (!this._wallet) throw new Error("no walletd URL configured"); if (this._wallet) return this._wallet.current();
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) { plan(spec) {
if (!this._wallet) throw new Error("no walletd URL configured"); if (!this._wallet) throw new Error("no walletd URL configured");
@ -162,8 +266,12 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
xprv: this._keys.seedHex, 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() { dispose() {
this._stopSiascanPoll();
try { this._wallet && this._wallet.dispose(); } catch {} try { this._wallet && this._wallet.dispose(); } catch {}
try { this._keys && this._keys.wipe(); } catch {} try { this._keys && this._keys.wipe(); } catch {}
if (this._root) this._root.fill(0); if (this._root) this._root.fill(0);

View file

@ -109,10 +109,10 @@ module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore,
async refresh(full) { async refresh(full) {
this._state.scanning = true; this._emit(); this._state.scanning = true; this._emit();
try { 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) }; this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
if (full) { 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) => ({ this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0, 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. // npm packages — same "hand it in" pattern the other adapters use.
module.exports = function makeImportDerive({ 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, cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39,
dgbCore, 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 toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const fromHex = (h) => { const fromHex = (h) => {
const s = String(h || "").replace(/^0x/i, ""); const s = String(h || "").replace(/^0x/i, "");
@ -207,6 +211,42 @@ module.exports = function makeImportDerive({
return base58check.encodeBase58(pub); 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 { return {
mnemonicToSeedHex, mnemonicToSeedHex,
btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif }, btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif },
@ -214,5 +254,6 @@ module.exports = function makeImportDerive({
eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex }, eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex },
trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex }, trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex },
sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 }, 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 {} 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 // Fiat prices for every Aegis-supported coin. Poll every enabled source in
// privacy-conscious user isn't quietly telling ANY oracle when Aegis is // parallel and reconcile per chain: if two or more sources agree within a
// open. Source is user-selectable — different oracles trade off privacy, // small band (±3% of the median), take their median as the truth; if none
// coverage, and freshness: // 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, // The user-facing model in 0.6.36+ is just "on / off": no source picker,
// default. Sees the browser IP + User-Agent every poll. // no per-source config. Adding a new oracle here fans out to everyone
// - kraken : per-pair spot from Kraken's public /Ticker; fewer // with no UI churn.
// 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.
// //
// New sources plug in by adding an entry to SOURCES. Each provider takes a // Sources currently wired:
// list of chain keys and returns { <chain>: usd } for the ones it knows // coingecko — 1 request covers all 7 coins, best overall coverage
// about; unknown chains just stay absent from the snapshot. The poller is // kraken — public /Ticker; BCH/BTC/ETH/SOL/TRX pairs
// generic. // 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 // Sources deferred (need their own protocol work first):
// per-source since some rate-limit tighter than others. Off by default. // 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"]; 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 = { const SOURCES = {
coingecko: { coingecko: {
id: "coingecko", id: "coingecko",
label: "CoinGecko", label: "CoinGecko",
origin: "api.coingecko.com", origin: "api.coingecko.com",
pollMs: 5 * 60 * 1000,
coversAll: true, coversAll: true,
fetch: async () => { fetch: async () => {
const ids = { const ids = {
@ -49,18 +56,14 @@ const SOURCES = {
id: "kraken", id: "kraken",
label: "Kraken", label: "Kraken",
origin: "api.kraken.com", origin: "api.kraken.com",
pollMs: 60 * 1000,
coversAll: false, coversAll: false,
fetch: async () => { 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 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 url = `https://api.kraken.com/0/public/Ticker?pair=${Object.values(pairs).join(",")}`;
const r = await fetch(url); const r = await fetch(url);
if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`); if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`);
const body = await r.json(); const body = await r.json();
if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";")); if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";"));
// Kraken returns keys like "XBCHZUSD" — match by suffix.
const out = {}; const out = {};
const result = body?.result || {}; const result = body?.result || {};
const entries = Object.entries(result); const entries = Object.entries(result);
@ -76,11 +79,8 @@ const SOURCES = {
id: "coinbase", id: "coinbase",
label: "Coinbase", label: "Coinbase",
origin: "api.coinbase.com", origin: "api.coinbase.com",
pollMs: 60 * 1000,
coversAll: false, coversAll: false,
fetch: async () => { 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 map = { bch: "BCH-USD", btc: "BTC-USD", eth: "ETH-USD", sol: "SOL-USD" };
const out = {}; const out = {};
await Promise.all(Object.entries(map).map(async ([chain, pair]) => { await Promise.all(Object.entries(map).map(async ([chain, pair]) => {
@ -95,35 +95,154 @@ const SOURCES = {
return out; 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 = () => {} } = {}) { module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
const state = { const state = {
enabled: false, enabled: false,
source: DEFAULT_SOURCE, prices: {}, // { <chain>: usd (number) } — backward-compat
prices: {}, // { <chain>: usd (number) } reconciled: {}, // { <chain>: {usd, method, samples: [...]} }
sourceStatus: {}, // { <sourceId>: {ok, error, prices, at, took} }
fetchedAt: null, fetchedAt: null,
error: null, error: null,
loading: false, loading: false,
}; };
let timer = null; let timer = null;
function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; }
async function fetchOnce() { async function fetchOnce() {
if (!state.enabled) return; if (!state.enabled) return;
state.loading = true; state.error = null; onChange(); state.loading = true; state.error = null; onChange();
try { try {
const src = currentProvider(); const { perChain, sourceStatus } = await pollAll(log);
const next = await src.fetch(); const flat = {};
state.prices = next || {}; 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.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) { } catch (e) {
state.error = e?.message || String(e); state.error = e?.message || String(e);
log(`price fetch (${state.source}) failed:`, state.error); log("price poll failed:", state.error);
} finally { } finally {
state.loading = false; state.loading = false;
onChange(); onChange();
@ -133,30 +252,31 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
function schedule() { function schedule() {
clearTimeout(timer); clearTimeout(timer);
if (!state.enabled) return; if (!state.enabled) return;
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs); timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
} }
return { return {
snapshot() { snapshot() {
return { return {
enabled: state.enabled, enabled: state.enabled,
source: state.source,
prices: state.prices, prices: state.prices,
reconciled: state.reconciled,
sourceStatus: state.sourceStatus,
fetchedAt: state.fetchedAt, fetchedAt: state.fetchedAt,
error: state.error, error: state.error,
loading: state.loading, loading: state.loading,
sources: Object.values(SOURCES).map((s) => ({ // Retained so existing settings UI paths that expect a `sources`
id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll, // 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) { async setEnabled(on) {
const changed = !!on !== state.enabled; const changed = !!on !== state.enabled;
state.enabled = !!on; state.enabled = !!on;
if (!state.enabled) { 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); clearTimeout(timer);
if (changed) onChange(); if (changed) onChange();
return; return;
@ -165,15 +285,10 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
await fetchOnce(); await fetchOnce();
schedule(); schedule();
}, },
// Switch source. Clears the current cache, kicks a fresh fetch if the // No-op kept for API compatibility — there is no source picker anymore.
// feed is enabled. No-op when the source is already current. // Existing callers that persisted a chosen source can still call this
async setSource(id) { // and get a benign refresh.
if (!SOURCES[id] || id === state.source) return; async setSource(_id) { if (state.enabled) { await fetchOnce(); schedule(); } },
state.source = id;
state.prices = {}; state.fetchedAt = null;
onChange();
if (state.enabled) { await fetchOnce(); schedule(); }
},
refresh() { return fetchOnce(); }, refresh() { return fetchOnce(); },
dispose() { clearTimeout(timer); state.enabled = false; }, 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 outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout));
const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE; 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) }] // inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }]
// outputs: [{ value, script(Uint8Array) }] // outputs: [{ value, script(Uint8Array) }]
@ -81,42 +96,51 @@ module.exports = function makeTx({ sha256 }) {
return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) }; return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) };
} }
// Largest-first accumulation. `targets` = [{ value, script }]; returns // Largest-first accumulation. `targets` = [{ value, script, data? }]:
// { inputs, outputs, fee, change } or throws when funds don't cover it. // data:true — an OP_RETURN memo output; value MUST be 0 and doesn't
// sendMax: spend every UTXO into targets[0] and no change. // 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 } = {}) { 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 sorted = utxos.slice().sort((a, b) => b.value - a.value);
const total = sorted.reduce((a, u) => a + u.value, 0); const total = sorted.reduce((a, u) => a + u.value, 0);
if (sendMax) { if (sendMax) {
if (targets.length !== 1) throw new Error("send max needs exactly one recipient"); if (spendOuts.length !== 1) throw new Error("send max needs exactly one recipient");
const fee = feeFor(sorted.length, 1, satPerByte); const fee = feeFor(sorted.length, 1 + dataOuts.length, satPerByte, dataBytes);
const value = total - fee; const value = total - fee;
if (!sorted.length || value < DUST) throw new Error("balance too small to send"); 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); const want = spendOuts.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)`); for (const t of spendOuts) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
const chosen = []; let sum = 0; const chosen = []; let sum = 0;
for (const u of sorted) { for (const u of sorted) {
chosen.push(u); sum += u.value; 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) { if (sum >= want + feeWithChange) {
const change = sum - want - feeWithChange; const change = sum - want - feeWithChange;
if (change >= DUST) { 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. // Change would be dust: fold it into the fee, one output fewer.
const fee = sum - want; 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) { 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`); 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: // Wallet state machine on top of an electrum client and a WalletKeys tree:
// address discovery (gap limit), balance, history with per-tx deltas, UTXO // address discovery (gap limit), balance, history with per-tx deltas, UTXO
// set and send construction. Knows nothing about UI or IPC. // 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 = () => {} }) { module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) {
const GAP = 20; const GAP = 20;
const HISTORY_LIMIT = 25; const HISTORY_LIMIT = 25;
@ -11,7 +20,8 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
watched: new Map(), // scripthash -> entry watched: new Map(), // scripthash -> entry
height: 0, height: 0,
balance: { confirmed: 0, unconfirmed: 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 history: [], // newest first
receiveIndex: 0, receiveIndex: 0,
scanning: false, scanning: false,
@ -70,12 +80,70 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
async function loadUtxos() { async function loadUtxos() {
const lists = await Promise.all([...state.watched.values()].map(async (e) => { const lists = await Promise.all([...state.watched.values()].map(async (e) => {
const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]); 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(); const utxos = lists.flat();
let confirmed = 0, unconfirmed = 0; // Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript
for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } // can classify it. getTx() already caches to disk, so a re-scan on a
state.balance = { confirmed, unconfirmed }; // 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) { 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. // 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 rate = Math.min(10, Math.max(1, Number(feeRate) || 1));
const outs = targets.map((t) => { const outs = targets.map((t) => {
const a = cashaddr.parseAny(t.to, sha256, keys.prefix); 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]); : Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr }; return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
}); });
// Spend confirmed coins first; unconfirmed only when needed. if (memo) outs.push({ value: 0, script: tx.memoScript(memo), data: true, memo });
const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); // 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 }); 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) { async function signAndBroadcast(p) {
@ -232,6 +318,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
height: state.height, height: state.height,
history: state.history, history: state.history,
utxoCount: state.utxos.length, 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, scanning: state.scanning,
error: state.error, 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 .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 { 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; } .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 /* Full-panel sheet — fills most of the sidebar but stops above the
import form aren't squeezed into a small popover. */ footer so the aegis.x brand + version chip stay visible. Users
#drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 0; 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); background: var(--panel); border-top: 1px solid var(--line);
box-shadow: 0 -6px 26px rgba(0,0,0,.35); z-index: 20; box-shadow: 0 -6px 26px rgba(0,0,0,.35); z-index: 20;
display: flex; flex-direction: column; } display: flex; flex-direction: column; }
@ -66,9 +68,37 @@
#drop .netgroup[hidden] { display: none; } #drop .netgroup[hidden] { display: none; }
#drop .netchoice { padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); } #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); } #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; .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; } 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; } .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 { margin-top: 6px; color: var(--mut); font-size: 11.5px; }
.portfolio b { color: var(--ink); font-weight: 600; } .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; 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; } display: inline-flex; align-items: center; justify-content: center; }
.chip:hover { border-color: var(--acid); color: var(--acid); } .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 { 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; 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; } 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); .wstrip .wcname .wnetpill.wchipnet { background: rgb(from var(--acid, #d6ff3d) r g b / .18);
color: var(--acid, #d6ff3d); font-weight: 600; } color: var(--acid, #d6ff3d); font-weight: 600; }
.wstrip .wcname .wnetpill.wtestnet { background: rgba(224,179,65,.18); color: #e0b341; 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; .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; } 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; .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; .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; } 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 .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; /* Inline address-list row (per-coin drilldown). 0.6.35 layout: strictly
gap: 6px; align-items: center; padding: 5px 4px; columnar so every row's fields line up in the same x positions no
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 30px; } 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:hover { background: rgba(255,255,255,.04); }
.wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10); .wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35); } 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; .wstrip .warow .waaddr { font: 12px/1.15 ui-monospace, Consolas, monospace; color: var(--ink);
white-space: nowrap; font-weight: 500; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.wstrip .warow .waaddr { font: 11px/1.15 ui-monospace, Consolas, monospace; color: var(--dim); margin-top: 2px; /* Copy chip anchored right after the address. Fixed 22px column so the
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } copy button sits at the same x on every row. */
.wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--ink); } .wstrip .warow .wacopy { background: transparent; border: 0; color: var(--dim); cursor: pointer;
.wstrip .warow .wafiat { font-size: 11px; color: var(--dim); } 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; .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; } 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); } .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 /* 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 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)); } 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 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 .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=password],
#lockScreen .lockform input[type=text], #lockScreen .lockform input[type=text] { text-align: center; }
#lockScreen .lockform textarea { 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 { 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 { color: var(--acid, #d6ff3d); cursor: pointer; text-decoration: none; }
#lockScreen .altline a:hover { text-decoration: underline; } #lockScreen .altline a:hover { text-decoration: underline; }
@ -396,14 +539,25 @@
<div class="t"> <div class="t">
<span class="badge" id="hBadge"></span> <span class="badge" id="hBadge"></span>
<span class="lbl" id="hLabel">Aegis Wallet</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>
<div class="picker-actions"> <div class="picker-actions">
<button type="button" id="hAdd" class="chip" title="Add a new wallet">+</button> <!-- Single right-corner chip. Opens a compact popover menu with
<button type="button" id="hMore" class="chip" title="Import / Connect / About">⋯</button> Create new / Import / Connect / About — replacing the old
<button type="button" id="hManage" class="chip" title="Edit this wallet — rename, derivation path, remove">✎</button> 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>
</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 id="drop" hidden></div>
<div class="bal"> <div class="bal">
<div class="big"> <div class="big">
@ -432,6 +586,15 @@
<div id="gate" class="gate" hidden></div> <div id="gate" class="gate" hidden></div>
<div id="tabs"> <div id="tabs">
<section id="tab-receive"> <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="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
<div class="card"> <div class="card">
<div class="lbl">Receiving address <span id="addrMeta"></span></div> <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="card" id="tokensCard" hidden style="margin-top:12px">
<div class="lbl">Tokens</div> <div class="lbl">Tokens</div>
<div id="tokensList" class="kv"></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> </div>
</section> </section>
<section id="tab-send" hidden> <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="field" id="sendAssetField" hidden>
<div class="lbl">Asset</div> <div class="lbl">Asset</div>
<select id="sendAsset"></select> <select id="sendAsset"></select>
@ -472,6 +655,15 @@
<div class="lbl">Fee</div> <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 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> </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="card">
<div class="summary"> <div class="summary">
<div class="k">Amount</div><div class="v" id="sumAmt">—</div> <div class="k">Amount</div><div class="v" id="sumAmt">—</div>
@ -481,11 +673,29 @@
</div> </div>
<div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div> <div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div>
<div class="msg" id="sendMsg" hidden></div> <div class="msg" id="sendMsg" hidden></div>
</div><!-- /sendNormal -->
<div id="sendConsolidate" hidden>
<div id="sendConsolidateInline"></div>
</div>
</section> </section>
<section id="tab-history" hidden> <section id="tab-history" hidden>
<div id="txlist"></div> <div id="txlist"></div>
</section> </section>
<section id="tab-settings" hidden> <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 <!-- Cross-cutting security / policy controls, ahead of anything
per-wallet or chain-specific. Present even when the vault is 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 still locked so users can set up a PIN or read the multi-sig
@ -515,6 +725,8 @@
</div> </div>
</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. <!-- Session: stay-signed-in + idle auto-lock + manual sign-out.
Uses electron.safeStorage under the hood so the master password Uses electron.safeStorage under the hood so the master password
is only decryptable under this OS user account. When "Lock on is only decryptable under this OS user account. When "Lock on
@ -570,7 +782,9 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- /session section -->
<div data-setsec-body="security" hidden>
<div class="card gsec" id="genMultiSig" style="margin-bottom:14px"> <div class="card gsec" id="genMultiSig" style="margin-bottom:14px">
<div class="gtitle">Second-device approval <span class="gsoon">soon</span></div> <div class="gtitle">Second-device approval <span class="gsoon">soon</span></div>
<div class="gline"> <div class="gline">
@ -583,7 +797,9 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- /security-multisig -->
<div data-setsec-body="wallet" hidden>
<div class="card" id="walletManage" style="margin-bottom:14px"> <div class="card" id="walletManage" style="margin-bottom:14px">
<div class="lbl">This wallet</div> <div class="lbl">This wallet</div>
<div class="field" style="margin-top:6px;margin-bottom:8px"> <div class="field" style="margin-top:6px;margin-bottom:8px">
@ -771,18 +987,18 @@
</div> </div>
</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="lbl">Fiat prices</div>
<div class="hint" style="margin-bottom:10px"> <div class="hint" style="margin-bottom:10px">
Off by default. When enabled, Aegis polls the chosen oracle for USD prices Off by default. When enabled, Aegis polls every supported oracle in
on each supported coin. No keys and no address data are ever sent — but the parallel and reconciles the results — two or more sources agreeing
oracle sees your IP + a User-Agent every poll, which is a signal that a within ±3% form the trusted price, so no single oracle can quietly
wallet is open on this machine. make Aegis show a wrong number. Every enabled oracle sees your IP
</div> + a User-Agent every poll, which is a signal that a wallet is open
<div class="field"> on this machine.
<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>
</div> </div>
<div class="actions" style="align-items:center;gap:12px"> <div class="actions" style="align-items:center;gap:12px">
<label class="switch"> <label class="switch">
@ -792,13 +1008,41 @@
<button class="btn sm" id="refreshPrices" hidden>Refresh now</button> <button class="btn sm" id="refreshPrices" hidden>Refresh now</button>
<span class="hint" id="pricesStatus" style="margin-left:auto"></span> <span class="hint" id="pricesStatus" style="margin-left:auto"></span>
</div> </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> </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="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 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 id="sites" class="kv"></div>
</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> <div class="msg err" id="settingsMsg" hidden></div>
</section> </section>
</div> </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> <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> <span>aegis.x</span>
</a> </a>
<!-- Update controls: a subtle "check" link that polls the OTA manifest, <!-- Update controls (0.8.1): the update chip lives INSIDE the right-side
then swaps to an "Update to vX.Y.Z" chip when a newer version is group, sharing the slot with the version marker. paintFooterUpdate()
ready. Panel-only view since the addon can't promote itself; the hides brandVer when a newer build is available and shows brandUpdate
chip links to Settings > Extensions > Aegis where the Apply flow in its place — clicking the acid-green "↑ v0.8.1" text stages the
lives. Version marker on the far right doubles as the up-to-date download and relaunches Theseus. Idle: brandVer visible, brandUpdate
affordance so a happy panel says nothing extra. --> hidden. Same one-click Update/Install path the user asked for, at
<span id="brandUpdate" class="brandupd" hidden></span> the exact spot they identified. -->
<span class="brandfoot-right"> <span class="brandfoot-right">
<button id="brandCheck" class="brandcheck" type="button" title="Check for updates">↻</button> <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 class="brandver" id="brandVer"></span>
</span> </span>
</footer> </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("tronWeb", tronWeb);
theseus.contextBridge.exposeInMainWorld("tronLink", tronLink); 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) --------------- // -------- Main-world bridges (window.ethereum, window.solana) ---------------
// //
// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet // EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet
@ -491,6 +521,23 @@ const mainWorldSource = `(function () {
announce(); announce();
window.addEventListener("eip6963:requestProvider", announce); window.addEventListener("eip6963:requestProvider", announce);
} catch {} } 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 // 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"); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("theseus", { contextBridge.exposeInMainWorld("theseus", {
navigate: (input) => ipcRenderer.invoke("navigate", input), navigate: (input) => ipcRenderer.invoke("navigate", input),
search: (q) => ipcRenderer.invoke("search", q), search: (q) => ipcRenderer.invoke("search", q),
newTab: () => ipcRenderer.invoke("new-tab"), newTab: () => ipcRenderer.invoke("new-tab"),
closeTab: (id) => ipcRenderer.invoke("close-tab", id), closeTab: (id) => ipcRenderer.invoke("close-tab", id),
switchTab: (id) => ipcRenderer.invoke("switch-tab", id), switchTab: (id) => ipcRenderer.invoke("switch-tab", id),
moveTab: (id, targetId, place) => ipcRenderer.invoke("move-tab", id, targetId, place), moveTab: (id, targetId, place) => ipcRenderer.invoke("move-tab", id, targetId, place),
suggestAddress: (query, rect) => ipcRenderer.invoke("suggest-address", query, rect), suggestAddress: (query, rect) => ipcRenderer.invoke("suggest-address", query, rect),
closeAddressPicker: () => ipcRenderer.invoke("close-address-picker"), closeAddressPicker: () => ipcRenderer.invoke("close-address-picker"),
addressCursor: (dir) => ipcRenderer.invoke("address-cursor", dir), addressCursor: (dir) => ipcRenderer.invoke("address-cursor", dir),
togglePwFill: (rect) => ipcRenderer.invoke("toggle-pw-fill", rect), togglePwFill: (rect) => ipcRenderer.invoke("toggle-pw-fill", rect),
onPwAvailability: (cb) => ipcRenderer.on("pw-availability", (_e, d) => cb(d)), onPwAvailability: (cb) => ipcRenderer.on("pw-availability", (_e, d) => cb(d)),
// Update chip: chrome subscribes to update-available; clicking the chip's // Update chip: chrome subscribes to update-available; clicking the chip's
// download button opens the URL in the system browser; ✕ dismisses for // download button opens the URL in the system browser; ✕ dismisses for
// the current session. // the current session.
onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)), onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)),
// Signed extension updates staged by the background check: { staged: // Signed extension updates staged by the background check: { staged:
// [{id,name,version}] }. They apply on the next launch, so the chrome // [{id,name,version}] }. They apply on the next launch, so the chrome
// offers a restart. stagedAddons() asks once at boot. // offers a restart. stagedAddons() asks once at boot.
onAddonUpdates: (cb) => ipcRenderer.on("addon-updates", (_e, d) => cb(d)), onAddonUpdates: (cb) => ipcRenderer.on("addon-updates", (_e, d) => cb(d)),
stagedAddons: () => ipcRenderer.invoke("addons-list-staged"), stagedAddons: () => ipcRenderer.invoke("addons-list-staged"),
applyStagedAddons: () => ipcRenderer.invoke("addons-apply-staged"), applyStagedAddons: () => ipcRenderer.invoke("addons-apply-staged"),
restartApp: () => ipcRenderer.invoke("app-restart"), restartApp: () => ipcRenderer.invoke("app-restart"),
openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url), openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url),
installUpdateNow: () => ipcRenderer.invoke("install-update-now"), installUpdateNow: () => ipcRenderer.invoke("install-update-now"),
dismissUpdate: () => ipcRenderer.invoke("dismiss-update"), dismissUpdate: () => ipcRenderer.invoke("dismiss-update"),
goHome: () => ipcRenderer.invoke("go-home"), goHome: () => ipcRenderer.invoke("go-home"),
back: () => ipcRenderer.invoke("go-back"), back: () => ipcRenderer.invoke("go-back"),
forward: () => ipcRenderer.invoke("go-forward"), forward: () => ipcRenderer.invoke("go-forward"),
// Press-and-hold / right-click on Back or Forward: native menu of the // 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. // tab's history entries in that direction; picking one jumps to it.
navHistoryMenu: (dir, rect) => ipcRenderer.invoke("nav-history-menu", dir, rect), navHistoryMenu: (dir, rect) => ipcRenderer.invoke("nav-history-menu", dir, rect),
reload: (hard) => ipcRenderer.invoke("reload", !!hard), reload: (hard) => ipcRenderer.invoke("reload", !!hard),
stop: () => ipcRenderer.invoke("stop"), stop: () => ipcRenderer.invoke("stop"),
toggleTor: () => ipcRenderer.invoke("toggle-tor"), toggleTor: () => ipcRenderer.invoke("toggle-tor"),
openSettings: (section) => ipcRenderer.invoke("open-settings", section), openSettings: (section) => ipcRenderer.invoke("open-settings", section),
getSettings: () => ipcRenderer.invoke("settings-get"), getSettings: () => ipcRenderer.invoke("settings-get"),
setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val), setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val),
onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)), onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)),
// Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer // Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer
// owns the bar UI and drives findInPage / stopFindInPage via these // owns the bar UI and drives findInPage / stopFindInPage via these
// wrappers. Match count / active ordinal comes back through onFindResult. // wrappers. Match count / active ordinal comes back through onFindResult.
onFindOpen: (cb) => ipcRenderer.on("find-open", () => cb()), onFindOpen: (cb) => ipcRenderer.on("find-open", () => cb()),
findInPage: (query, opts) => ipcRenderer.invoke("find-in-page", query, opts || {}), findInPage: (query, opts) => ipcRenderer.invoke("find-in-page", query, opts || {}),
findStop: () => ipcRenderer.invoke("find-stop"), findStop: () => ipcRenderer.invoke("find-stop"),
onFindResult: (cb) => ipcRenderer.on("find-result", (_e, d) => cb(d)), onFindResult: (cb) => ipcRenderer.on("find-result", (_e, d) => cb(d)),
toggleSiteInfo: (rect) => ipcRenderer.invoke("toggle-site-info", rect), toggleSiteInfo: (rect) => ipcRenderer.invoke("toggle-site-info", rect),
switchToBcnr: () => ipcRenderer.invoke("switch-to-bcnr"), switchToBcnr: () => ipcRenderer.invoke("switch-to-bcnr"),
setChromeHeight: (h) => ipcRenderer.invoke("set-chrome-height", h), setChromeHeight: (h) => ipcRenderer.invoke("set-chrome-height", h),
getSearchEngines: () => ipcRenderer.invoke("search-engines"), getSearchEngines: () => ipcRenderer.invoke("search-engines"),
setSearchEngine: (id) => ipcRenderer.invoke("set-search-engine", id), setSearchEngine: (id) => ipcRenderer.invoke("set-search-engine", id),
addEngine: (eng) => ipcRenderer.invoke("add-engine", eng), addEngine: (eng) => ipcRenderer.invoke("add-engine", eng),
removeEngine: (id) => ipcRenderer.invoke("remove-engine", id), removeEngine: (id) => ipcRenderer.invoke("remove-engine", id),
toggleEnginePicker: (rect) => ipcRenderer.invoke("toggle-engine-picker", rect), toggleEnginePicker: (rect) => ipcRenderer.invoke("toggle-engine-picker", rect),
onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)), onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)),
getBookmarks: () => ipcRenderer.invoke("bookmarks-get"), getBookmarks: () => ipcRenderer.invoke("bookmarks-get"),
addBookmark: (bm) => ipcRenderer.invoke("bookmark-add", bm), addBookmark: (bm) => ipcRenderer.invoke("bookmark-add", bm),
updateBookmark: (url, patch) => ipcRenderer.invoke("bookmark-update", url, patch), updateBookmark: (url, patch) => ipcRenderer.invoke("bookmark-update", url, patch),
removeBookmark: (url) => ipcRenderer.invoke("bookmark-remove", url), removeBookmark: (url) => ipcRenderer.invoke("bookmark-remove", url),
moveBookmark: (fromUrl, targetUrl, place) => ipcRenderer.invoke("bookmark-move", fromUrl, targetUrl, place), moveBookmark: (fromUrl, targetUrl, place) => ipcRenderer.invoke("bookmark-move", fromUrl, targetUrl, place),
onBookmarks: (cb) => ipcRenderer.on("bookmarks", (_e, d) => cb(d)), onBookmarks: (cb) => ipcRenderer.on("bookmarks", (_e, d) => cb(d)),
onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)), onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)),
onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)), onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)),
onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)), onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)),
tabReload: (id) => ipcRenderer.invoke("tab-reload", id), tabReload: (id) => ipcRenderer.invoke("tab-reload", id),
tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id), tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id),
tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on), tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on),
tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color), tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color),
tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color), tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color),
tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id), tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id),
onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)), onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)),
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)), onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),
// Collision-mode (BCNR ↔ ICANN) live switcher for the active tab // Collision-mode (BCNR ↔ ICANN) live switcher for the active tab
collisionSwitch: (arg) => ipcRenderer.invoke("collision-switch", arg), collisionSwitch: (arg) => ipcRenderer.invoke("collision-switch", arg),
collisionState: () => ipcRenderer.invoke("collision-state"), collisionState: () => ipcRenderer.invoke("collision-state"),
// Downloads — the toolbar button subscribes to `downloads` to update its // Downloads — the toolbar button subscribes to `downloads` to update its
// badge, and toggleDownloads opens/closes the floating panel. // badge, and toggleDownloads opens/closes the floating panel.
getDownloads: () => ipcRenderer.invoke("downloads-get"), getDownloads: () => ipcRenderer.invoke("downloads-get"),
toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect), toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect),
onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)), onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)),
// Add-on sidebar: toggle, open on a specific panel, close, or subscribe // Add-on sidebar: toggle, open on a specific panel, close, or subscribe
// to state (visibility + which panel is active + the panel list). The // to state (visibility + which panel is active + the panel list). The
// extension dock in chrome.html renders one button per panel and calls // extension dock in chrome.html renders one button per panel and calls
// openSidebar / closeSidebar accordingly. // openSidebar / closeSidebar accordingly.
toggleSidebar: () => ipcRenderer.invoke("sidebar-toggle"), toggleSidebar: () => ipcRenderer.invoke("sidebar-toggle"),
openSidebar: (panelId) => ipcRenderer.invoke("sidebar-open", panelId), openSidebar: (panelId) => ipcRenderer.invoke("sidebar-open", panelId),
closeSidebar: () => ipcRenderer.invoke("sidebar-close"), closeSidebar: () => ipcRenderer.invoke("sidebar-close"),
sidebarState: () => ipcRenderer.invoke("sidebar-state"), sidebarState: () => ipcRenderer.invoke("sidebar-state"),
onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)), onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)),
// Extension dock: drag-reorder (key = "p:<panelId>" | "m:<addonId>") and // Extension dock: drag-reorder (key = "p:<panelId>" | "m:<addonId>") and
// the right-click menu (native; main owns the actions). // the right-click menu (native; main owns the actions).
dockMove: (key, targetKey, place) => ipcRenderer.invoke("dock-move", key, targetKey, place), dockMove: (key, targetKey, place) => ipcRenderer.invoke("dock-move", key, targetKey, place),
dockMenu: (key, rect) => ipcRenderer.invoke("dock-menu", key, rect), dockMenu: (key, rect) => ipcRenderer.invoke("dock-menu", key, rect),
// Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked // Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked
// item id to the add-on's "menu-select" handler. // item id to the add-on's "menu-select" handler.
addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId), addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId),
// Pop the dock-icon dropdown as a NATIVE menu. Renderer-side DOM popovers // 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 // 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 // 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. // layering entirely. Pass the button's viewport-rect so main can anchor.
toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect), toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect),
tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, 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 // Ariadne's Thread menu behind the registry button at the end of the
// address bar (BCDN/ICANN switch, remembered choices, policy, settings). // address bar (BCDN/ICANN switch, remembered choices, policy, settings).
registryMenuPopup: (rect) => ipcRenderer.invoke("registry-menu-popup", rect), 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 %. // Page zoom on the active tab: step +1 / -1 along Chrome's ladder, or reset to 100 %.
zoomStep: (dir) => ipcRenderer.invoke("zoom-step", dir), zoomStep: (dir) => ipcRenderer.invoke("zoom-step", dir),
zoomReset: () => ipcRenderer.invoke("zoom-reset"), zoomReset: () => ipcRenderer.invoke("zoom-reset"),
// Install-as-app chip in the address bar: main decides between the // Install-as-app chip in the address bar: main decides between the
// install dialog and the open/remove menu (anchored at rect). // install dialog and the open/remove menu (anchored at rect).
webappChip: (rect) => ipcRenderer.invoke("webapp-chip", rect), webappChip: (rect) => ipcRenderer.invoke("webapp-chip", rect),
// Chrome listens so it can drop the "active" tint when the native menu // Chrome listens so it can drop the "active" tint when the native menu
// dismisses (Esc, outside click, item picked — main fires all three). // dismisses (Esc, outside click, item picked — main fires all three).
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()), 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 // 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 // Edge offer it. Electron ships Chromium's renderer without the browser-side
// web-app machinery, so `beforeinstallprompt` never fires in an Electron app // 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 // and every site's own "Install our app" chip stays hidden. This module fills
// that gap on the browser side: // that gap on the browser side:
// //
// · probeTab() reads a page's <link rel="manifest">, checks it describes // · probeTab() reads a page's <link rel="manifest">, checks it describes
// an installable app (a name plus a standalone-style display // an installable app (a name plus a standalone-style display
// mode, start_url on the page's own origin) and records the // mode, start_url on the page's own origin) and records the
// descriptor on the tab. main.js then shows the address-bar // descriptor on the tab. main.js then shows the address-bar
// chip and fires a synthetic `beforeinstallprompt` so the // chip and fires a synthetic `beforeinstallprompt` so the
// site's own chip appears and works too. // site's own chip appears and works too.
// · install() asks the user (native dialog), stores the app under // · install() asks the user (native dialog), stores the app under
// <userData>/webapps/, turns the manifest icon into an .ico // <userData>/webapps/, turns the manifest icon into an .ico
// and writes Start Menu / desktop shortcuts that launch // and writes Start Menu / desktop shortcuts that launch
// Theseus with `--app=<start_url>`. // Theseus with `--app=<start_url>`.
// · open() the chromeless app window: same session, same BCNR // · open() the chromeless app window: same session, same BCNR
// resolution, same add-on bridges as a tab, own taskbar // resolution, same add-on bridges as a tab, own taskbar
// identity so it can be pinned like any app. // identity so it can be pinned like any app.
// · launch() what `--app=` resolves to at startup or on a second // · launch() what `--app=` resolves to at startup or on a second
// instance. // instance.
// //
// Windows-first: shortcuts and taskbar identity are Windows APIs; on other // Windows-first: shortcuts and taskbar identity are Windows APIs; on other
// platforms the app still installs and opens, only without shortcuts. // platforms the app still installs and opens, only without shortcuts.
const { app, BrowserWindow, dialog, nativeImage, shell, session, Menu, clipboard, nativeTheme } = require("electron"); const { app, BrowserWindow, dialog, nativeImage, shell, session, Menu, clipboard, nativeTheme } = require("electron");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const crypto = require("crypto"); const crypto = require("crypto");
// Display modes that mean "this wants to be an app, not a page". // 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"]); 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 // 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 // page itself would (same-origin cookies unless the link opts into CORS
// credentials). Returns { href, text } or null. // credentials). Returns { href, text } or null.
const PROBE_SRC = `(async () => { try { const PROBE_SRC = `(async () => { try {
const l = document.querySelector('link[rel~="manifest"]'); if (!l || !l.href) return null; 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" }); 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() }; if (!r.ok) return null; return { href: l.href, text: await r.text() };
} catch (e) { return null; } })()`; } catch (e) { return null; } })()`;
// Runs inside the page after a successful probe: the synthetic // Runs inside the page after a successful probe: the synthetic
// beforeinstallprompt. prompt() asks Theseus through a DOM event that the // beforeinstallprompt. prompt() asks Theseus through a DOM event that the
// session preload (bcnr-preload.js) relays over IPC; the answer comes back // session preload (bcnr-preload.js) relays over IPC; the answer comes back
// as another DOM event, and userChoice resolves with Chrome's shape. // as another DOM event, and userChoice resolves with Chrome's shape.
const PROMPT_SRC = `(() => { try { const PROMPT_SRC = `(() => { try {
if (window.__theseusInstallPrompt) return; window.__theseusInstallPrompt = 1; if (window.__theseusInstallPrompt) return; window.__theseusInstallPrompt = 1;
const e = new Event("beforeinstallprompt", { cancelable: true }); const e = new Event("beforeinstallprompt", { cancelable: true });
e.platforms = ["windows"]; e.platforms = ["windows"];
let done; e.userChoice = new Promise((r) => { done = r; }); 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-accepted", () => done({ outcome: "accepted", platform: "windows" }), { once: true });
document.addEventListener("theseus:webapp-dismissed", () => done({ outcome: "dismissed", 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; }; e.prompt = () => { document.dispatchEvent(new Event("theseus:webapp-prompt")); return e.userChoice; };
window.dispatchEvent(e); window.dispatchEvent(e);
} catch (err) {} })()`; } catch (err) {} })()`;
const INSTALLED_SRC = `try { window.dispatchEvent(new Event("appinstalled")); } catch (e) {}`; const INSTALLED_SRC = `try { window.dispatchEvent(new Event("appinstalled")); } catch (e) {}`;
let deps = {}; // supplied by main.js — see init() let deps = {}; // supplied by main.js — see init()
let apps = []; // installed apps (persisted) let apps = []; // installed apps (persisted)
let dir = null, file = null; let dir = null, file = null;
const windows = new Map(); // key -> BrowserWindow const windows = new Map(); // key -> BrowserWindow
let promptOpen = false; let promptOpen = false;
function init(d) { function init(d) {
deps = d; deps = d;
dir = path.join(app.getPath("userData"), "webapps"); dir = path.join(app.getPath("userData"), "webapps");
file = path.join(dir, "apps.json"); file = path.join(dir, "apps.json");
try { apps = JSON.parse(fs.readFileSync(file, "utf8")); } catch { apps = []; } try { apps = JSON.parse(fs.readFileSync(file, "utf8")); } catch { apps = []; }
if (!Array.isArray(apps)) apps = []; if (!Array.isArray(apps)) apps = [];
} }
function save() { 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); } 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 list = () => apps.slice();
const find = (key) => apps.find((a) => a.key === key) || null; const find = (key) => apps.find((a) => a.key === key) || null;
const str = (v) => (typeof v === "string" ? v.trim() : ""); const str = (v) => (typeof v === "string" ? v.trim() : "");
// Pages on BNS names load as bns://host/…; the app is stored under its // 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 // https form (what the user sees, what the shortcut carries). targetUrlFor
// turns it back into bns:// at launch when the resolver says so. // turns it back into bns:// at launch when the resolver says so.
const norm = (u) => String(u || "").replace(/^bns:\/\//i, "https://"); const norm = (u) => String(u || "").replace(/^bns:\/\//i, "https://");
function originOf(u) { try { return new URL(norm(u)).origin; } catch { return ""; } } 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 keyFor = (id) => crypto.createHash("sha1").update(id).digest("hex").slice(0, 16);
const aumidFor = (key) => `st.silentmode.theseus.app.${key}`; const aumidFor = (key) => `st.silentmode.theseus.app.${key}`;
// ---- manifest → descriptor ---------------------------------------------- // ---- manifest → descriptor ----------------------------------------------
function parseSizes(s) { function parseSizes(s) {
let best = 0; let best = 0;
for (const part of String(s || "").split(/\s+/)) { for (const part of String(s || "").split(/\s+/)) {
if (part === "any") return Infinity; 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])); const m = /^(\d+)x(\d+)$/i.exec(part); if (m) best = Math.max(best, Math.min(+m[1], +m[2]));
} }
return best; return best;
} }
function pickIcon(icons, base) { function pickIcon(icons, base) {
if (!Array.isArray(icons)) return null; if (!Array.isArray(icons)) return null;
let best = null; let best = null;
for (const ic of icons) { for (const ic of icons) {
if (!ic || typeof ic !== "object" || !str(ic.src)) continue; if (!ic || typeof ic !== "object" || !str(ic.src)) continue;
const type = str(ic.type).toLowerCase(); const type = str(ic.type).toLowerCase();
const svg = type === "image/svg+xml" || /\.svg(\?|$)/i.test(ic.src); 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 (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; 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 purpose = str(ic.purpose).toLowerCase().split(/\s+/).filter(Boolean);
const any = purpose.length === 0 || purpose.includes("any"); const any = purpose.length === 0 || purpose.includes("any");
const size = parseSizes(ic.sizes); const size = parseSizes(ic.sizes);
let href; try { href = new URL(ic.src, base).href; } catch { continue; } let href; try { href = new URL(ic.src, base).href; } catch { continue; }
// Rank: "any"-purpose over maskable-only, then the largest size up to // Rank: "any"-purpose over maskable-only, then the largest size up to
// 512 (bigger is only downscaled), then anything larger. // 512 (bigger is only downscaled), then anything larger.
const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4); 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 }; if (!best || rank > best.rank) best = { href, size, rank, any };
} }
return best; return best;
} }
// Turn a page URL + fetched manifest into an app descriptor, or null when the // Turn a page URL + fetched manifest into an app descriptor, or null when the
// manifest doesn't describe an installable app. // manifest doesn't describe an installable app.
function describe(pageUrl, manifestHref, text) { function describe(pageUrl, manifestHref, text) {
let m; try { m = JSON.parse(text); } catch { return null; } let m; try { m = JSON.parse(text); } catch { return null; }
if (!m || typeof m !== "object" || Array.isArray(m)) return null; if (!m || typeof m !== "object" || Array.isArray(m)) return null;
const name = str(m.name) || str(m.short_name); const name = str(m.name) || str(m.short_name);
if (!name) return null; if (!name) return null;
const display = APP_DISPLAYS.has(str(m.display)) || const display = APP_DISPLAYS.has(str(m.display)) ||
(Array.isArray(m.display_override) && m.display_override.some((d) => APP_DISPLAYS.has(str(d)))); (Array.isArray(m.display_override) && m.display_override.some((d) => APP_DISPLAYS.has(str(d))));
if (!display) return null; if (!display) return null;
let startRaw; try { startRaw = new URL(str(m.start_url) || ".", manifestHref).href; } catch { return null; } let startRaw; try { startRaw = new URL(str(m.start_url) || ".", manifestHref).href; } catch { return null; }
const startUrl = norm(startRaw).replace(/#.*$/, ""); const startUrl = norm(startRaw).replace(/#.*$/, "");
const origin = originOf(pageUrl); const origin = originOf(pageUrl);
if (!origin || originOf(startUrl) !== origin) return null; if (!origin || originOf(startUrl) !== origin) return null;
let scope; let scope;
try { scope = norm(new URL(str(m.scope) || ".", startRaw).href).replace(/[?#].*$/, ""); } catch { 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(/[^/]*$/, ""); 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; } let id; try { id = new URL(str(m.id) || startUrl, startUrl).href; } catch { id = startUrl; }
id = norm(id); id = norm(id);
const icon = pickIcon(m.icons, manifestHref); const icon = pickIcon(m.icons, manifestHref);
return { return {
key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40), 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 ""; } })(), startUrl, scope, origin, host: (() => { try { return new URL(origin).host; } catch { return ""; } })(),
manifestUrl: manifestHref, manifestUrl: manifestHref,
iconUrl: icon ? icon.href : null, iconSize: icon ? icon.size : 0, 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, 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, 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 || "")); } function probeable(url) { return /^(https?|bns):\/\//i.test(String(url || "")); }
// Read the active document's manifest and record the app descriptor (or // Read the active document's manifest and record the app descriptor (or
// null) on the tab. Returns the descriptor. Fires the synthetic // null) on the tab. Returns the descriptor. Fires the synthetic
// beforeinstallprompt when the app isn't installed yet. // beforeinstallprompt when the app isn't installed yet.
async function probeTab(tab) { async function probeTab(tab) {
const wc = tab?.view?.webContents; const wc = tab?.view?.webContents;
if (!wc || wc.isDestroyed()) return null; if (!wc || wc.isDestroyed()) return null;
const pageUrl = wc.getURL(); const pageUrl = wc.getURL();
if (!probeable(pageUrl)) { tab.webapp = null; return null; } if (!probeable(pageUrl)) { tab.webapp = null; return null; }
let res = null; let res = null;
try { res = await wc.executeJavaScript(PROBE_SRC, true); } catch {} try { res = await wc.executeJavaScript(PROBE_SRC, true); } catch {}
if (wc.isDestroyed() || wc.getURL() !== pageUrl) return tab.webapp || null; // navigated away meanwhile 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; const desc = res && res.href && typeof res.text === "string" ? describe(pageUrl, res.href, res.text) : null;
tab.webapp = desc; tab.webapp = desc;
if (desc && !find(desc.key)) { try { await wc.executeJavaScript(PROMPT_SRC, true); } catch {} } if (desc && !find(desc.key)) { try { await wc.executeJavaScript(PROMPT_SRC, true); } catch {} }
return desc; return desc;
} }
// Chrome-shaped summary for the toolbar. // Chrome-shaped summary for the toolbar.
function chipState(tab) { function chipState(tab) {
const d = tab?.webapp; if (!d) return null; const d = tab?.webapp; if (!d) return null;
return { key: d.key, name: d.name, installed: !!find(d.key) }; return { key: d.key, name: d.name, installed: !!find(d.key) };
} }
// ---- icon ------------------------------------------------------------------ // ---- icon ------------------------------------------------------------------
// A .ico that simply wraps one PNG (valid since Vista; what most modern .ico // A .ico that simply wraps one PNG (valid since Vista; what most modern .ico
// files are). Windows scales it for every shell size. // files are). Windows scales it for every shell size.
function pngToIco(png) { function pngToIco(png) {
const w = png.readUInt32BE(16), h = png.readUInt32BE(20); 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 hdr = Buffer.alloc(6); hdr.writeUInt16LE(0, 0); hdr.writeUInt16LE(1, 2); hdr.writeUInt16LE(1, 4);
const ent = Buffer.alloc(16); const ent = Buffer.alloc(16);
ent[0] = w >= 256 ? 0 : w; ent[1] = h >= 256 ? 0 : h; ent[2] = 0; ent[3] = 0; 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); ent.writeUInt16LE(1, 4); ent.writeUInt16LE(32, 6); ent.writeUInt32LE(png.length, 8); ent.writeUInt32LE(22, 12);
return Buffer.concat([hdr, ent, png]); return Buffer.concat([hdr, ent, png]);
} }
async function fetchBytes(u) { async function fetchBytes(u) {
const r = await session.defaultSession.fetch(u, { cache: "force-cache" }); const r = await session.defaultSession.fetch(u, { cache: "force-cache" });
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error("HTTP " + r.status);
return Buffer.from(await r.arrayBuffer()); return Buffer.from(await r.arrayBuffer());
} }
function bundledIcon() { function bundledIcon() {
return app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico"); 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 // 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. // page favicon, then to the Theseus icon, so an install never fails on art.
async function writeIcon(desc, faviconUrl, appDir) { async function writeIcon(desc, faviconUrl, appDir) {
const candidates = [desc.iconUrl, faviconUrl].filter(Boolean); const candidates = [desc.iconUrl, faviconUrl].filter(Boolean);
for (const u of candidates) { for (const u of candidates) {
try { try {
const raw = await fetchBytes(u); const raw = await fetchBytes(u);
if (raw.length > 4 && raw.readUInt16LE(0) === 0 && raw.readUInt16LE(2) === 1) { // already an .ico if (raw.length > 4 && raw.readUInt16LE(0) === 0 && raw.readUInt16LE(2) === 1) { // already an .ico
fs.writeFileSync(path.join(appDir, "icon.ico"), raw); fs.writeFileSync(path.join(appDir, "icon.ico"), raw);
const img = nativeImage.createFromPath(path.join(appDir, "icon.ico")); const img = nativeImage.createFromPath(path.join(appDir, "icon.ico"));
if (!img.isEmpty()) fs.writeFileSync(path.join(appDir, "icon.png"), img.toPNG()); if (!img.isEmpty()) fs.writeFileSync(path.join(appDir, "icon.png"), img.toPNG());
return true; return true;
} }
let img = nativeImage.createFromBuffer(raw); let img = nativeImage.createFromBuffer(raw);
if (img.isEmpty()) continue; if (img.isEmpty()) continue;
const { width, height } = img.getSize(); const { width, height } = img.getSize();
if (width > 256 || height > 256 || width !== height) { if (width > 256 || height > 256 || width !== height) {
const s = Math.min(256, Math.max(width, height)); const s = Math.min(256, Math.max(width, height));
img = img.resize({ width: s, height: s, quality: "best" }); img = img.resize({ width: s, height: s, quality: "best" });
} }
const png = img.toPNG(); const png = img.toPNG();
fs.writeFileSync(path.join(appDir, "icon.png"), png); fs.writeFileSync(path.join(appDir, "icon.png"), png);
fs.writeFileSync(path.join(appDir, "icon.ico"), pngToIco(png)); fs.writeFileSync(path.join(appDir, "icon.ico"), pngToIco(png));
return true; return true;
} catch {} } catch {}
} }
try { fs.copyFileSync(bundledIcon(), path.join(appDir, "icon.ico")); } catch {} try { fs.copyFileSync(bundledIcon(), path.join(appDir, "icon.ico")); } catch {}
return false; return false;
} }
const icoPath = (entry) => path.join(dir, entry.key, "icon.ico"); const icoPath = (entry) => path.join(dir, entry.key, "icon.ico");
const pngPath = (entry) => path.join(dir, entry.key, "icon.png"); const pngPath = (entry) => path.join(dir, entry.key, "icon.png");
function windowIcon(entry) { function windowIcon(entry) {
for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} } for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} }
return bundledIcon(); return bundledIcon();
} }
// ---- shortcuts --------------------------------------------------------------- // ---- shortcuts ---------------------------------------------------------------
function launchSpec(entry) { function launchSpec(entry) {
const target = process.execPath; const target = process.execPath;
// Dev runs are `electron.exe <appdir>`; packaged builds are just the exe. // Dev runs are `electron.exe <appdir>`; packaged builds are just the exe.
const args = (app.isPackaged ? "" : `"${app.getAppPath()}" `) + `--app=${entry.startUrl}`; const args = (app.isPackaged ? "" : `"${app.getAppPath()}" `) + `--app=${entry.startUrl}`;
return { target, args }; return { target, args };
} }
function shortcutName(entry) { function shortcutName(entry) {
const base = entry.name.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60) || entry.host; 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. // 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); const clash = apps.some((a) => a.key !== entry.key && a.shortcutBase === base);
return clash ? `${base} (${entry.host})` : base; return clash ? `${base} (${entry.host})` : base;
} }
function writeShortcuts(entry, desktop) { function writeShortcuts(entry, desktop) {
const paths = []; const paths = [];
if (process.platform !== "win32") return paths; if (process.platform !== "win32") return paths;
const { target, args } = launchSpec(entry); const { target, args } = launchSpec(entry);
const name = shortcutName(entry); const name = shortcutName(entry);
entry.shortcutBase = name; entry.shortcutBase = name;
const dirs = [path.join(app.getPath("appData"), "Microsoft", "Windows", "Start Menu", "Programs")]; const dirs = [path.join(app.getPath("appData"), "Microsoft", "Windows", "Start Menu", "Programs")];
if (desktop) { try { dirs.push(app.getPath("desktop")); } catch {} } if (desktop) { try { dirs.push(app.getPath("desktop")); } catch {} }
for (const d of dirs) { for (const d of dirs) {
const lnk = path.join(d, `${name}.lnk`); const lnk = path.join(d, `${name}.lnk`);
try { try {
fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(d, { recursive: true });
const ok = shell.writeShortcutLink(lnk, "create", { const ok = shell.writeShortcutLink(lnk, "create", {
target, args, cwd: path.dirname(target), icon: icoPath(entry), iconIndex: 0, target, args, cwd: path.dirname(target), icon: icoPath(entry), iconIndex: 0,
appUserModelId: aumidFor(entry.key), description: `${entry.name} — runs in Theseus Navigator`, appUserModelId: aumidFor(entry.key), description: `${entry.name} — runs in Theseus Navigator`,
}); });
if (ok) paths.push(lnk); if (ok) paths.push(lnk);
} catch (e) { console.warn("[webapps] shortcut failed:", lnk, e?.message); } } catch (e) { console.warn("[webapps] shortcut failed:", lnk, e?.message); }
} }
return paths; return paths;
} }
function removeShortcuts(entry) { function removeShortcuts(entry) {
for (const p of entry.shortcuts || []) { try { fs.unlinkSync(p); } catch {} } for (const p of entry.shortcuts || []) { try { fs.unlinkSync(p); } catch {} }
} }
// ---- install / uninstall -------------------------------------------------------- // ---- install / uninstall --------------------------------------------------------
// Asks the user, then installs. `desc` comes from probeTab; `ctx` names the // Asks the user, then installs. `desc` comes from probeTab; `ctx` names the
// tab's webContents (for the appinstalled event) and favicon. Resolves // tab's webContents (for the appinstalled event) and favicon. Resolves
// { ok, entry } or { ok:false, error } ("cancelled" when the user said no). // { ok, entry } or { ok:false, error } ("cancelled" when the user said no).
async function install(desc, ctx = {}) { async function install(desc, ctx = {}) {
if (!desc || !desc.key) return { ok: false, error: "not installable" }; if (!desc || !desc.key) return { ok: false, error: "not installable" };
const have = find(desc.key); const have = find(desc.key);
if (have) { open(have); return { ok: true, entry: have, alreadyInstalled: true }; } if (have) { open(have); return { ok: true, entry: have, alreadyInstalled: true }; }
if (promptOpen) return { ok: false, error: "another install prompt is open" }; if (promptOpen) return { ok: false, error: "another install prompt is open" };
promptOpen = true; promptOpen = true;
try { try {
const parent = deps.parentWindow ? deps.parentWindow() : undefined; const parent = deps.parentWindow ? deps.parentWindow() : undefined;
let icon; // best effort preview in the dialog 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 {} 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, { const { response, checkboxChecked } = await dialog.showMessageBox(parent, {
type: "question", title: "Install app", icon, type: "question", title: "Install app", icon,
message: `Install ${desc.name}?`, 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.`, 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, buttons: ["Install", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
checkboxLabel: process.platform === "win32" ? "Also add a desktop shortcut" : undefined, checkboxLabel: process.platform === "win32" ? "Also add a desktop shortcut" : undefined,
checkboxChecked: process.platform === "win32", checkboxChecked: process.platform === "win32",
}); });
if (response !== 0) return { ok: false, error: "cancelled" }; if (response !== 0) return { ok: false, error: "cancelled" };
const entry = { const entry = {
key: desc.key, id: desc.id, name: desc.name, shortName: desc.shortName, key: desc.key, id: desc.id, name: desc.name, shortName: desc.shortName,
startUrl: desc.startUrl, scope: desc.scope, origin: desc.origin, host: desc.host, startUrl: desc.startUrl, scope: desc.scope, origin: desc.origin, host: desc.host,
manifestUrl: desc.manifestUrl, themeColor: desc.themeColor, backgroundColor: desc.backgroundColor, manifestUrl: desc.manifestUrl, themeColor: desc.themeColor, backgroundColor: desc.backgroundColor,
installedAt: new Date().toISOString(), shortcuts: [], bounds: null, installedAt: new Date().toISOString(), shortcuts: [], bounds: null,
}; };
const appDir = path.join(dir, entry.key); const appDir = path.join(dir, entry.key);
fs.mkdirSync(appDir, { recursive: true }); fs.mkdirSync(appDir, { recursive: true });
entry.hasIcon = await writeIcon(desc, ctx.favicon || null, appDir); entry.hasIcon = await writeIcon(desc, ctx.favicon || null, appDir);
entry.shortcuts = writeShortcuts(entry, !!checkboxChecked); entry.shortcuts = writeShortcuts(entry, !!checkboxChecked);
apps = apps.filter((a) => a.key !== entry.key); apps.push(entry); save(); 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 {} try { const wc = ctx.wc; if (wc && !wc.isDestroyed()) wc.executeJavaScript(INSTALLED_SRC, true).catch(() => {}); } catch {}
deps.changed && deps.changed(); deps.changed && deps.changed();
open(entry); open(entry);
return { ok: true, entry }; return { ok: true, entry };
} catch (e) { } catch (e) {
console.warn("[webapps] install failed:", e?.message); console.warn("[webapps] install failed:", e?.message);
return { ok: false, error: e?.message || "install failed" }; return { ok: false, error: e?.message || "install failed" };
} finally { promptOpen = false; } } finally { promptOpen = false; }
} }
async function uninstall(key, ask = true) { async function uninstall(key, ask = true) {
const entry = find(key); if (!entry) return false; const entry = find(key); if (!entry) return false;
if (ask) { if (ask) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined; const parent = deps.parentWindow ? deps.parentWindow() : undefined;
const { response } = await dialog.showMessageBox(parent, { const { response } = await dialog.showMessageBox(parent, {
type: "question", title: "Remove app", message: `Remove ${entry.name} from Theseus?`, 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.", 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, buttons: ["Remove", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
}); });
if (response !== 0) return false; if (response !== 0) return false;
} }
const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.close(); } catch {} } const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.close(); } catch {} }
removeShortcuts(entry); removeShortcuts(entry);
try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {} try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {}
apps = apps.filter((a) => a.key !== key); save(); apps = apps.filter((a) => a.key !== key); save();
deps.changed && deps.changed(); deps.changed && deps.changed();
return true; return true;
} }
// ---- the app window ---------------------------------------------------------- // ---- the app window ----------------------------------------------------------
function open(entry, urlOverride) { function open(entry, urlOverride) {
if (!entry) return null; if (!entry) return null;
const had = windows.get(entry.key); const had = windows.get(entry.key);
if (had && !had.isDestroyed()) { if (had && !had.isDestroyed()) {
if (urlOverride) had.webContents.loadURL(urlOverride).catch(() => {}); if (urlOverride) had.webContents.loadURL(urlOverride).catch(() => {});
if (had.isMinimized()) had.restore(); if (had.isMinimized()) had.restore();
had.focus(); had.focus();
return had; return had;
} }
const dark = nativeTheme.shouldUseDarkColors; const dark = nativeTheme.shouldUseDarkColors;
const b = entry.bounds && typeof entry.bounds === "object" ? entry.bounds : {}; const b = entry.bounds && typeof entry.bounds === "object" ? entry.bounds : {};
const w = new BrowserWindow({ const w = new BrowserWindow({
width: b.width || 1100, height: b.height || 760, width: b.width || 1100, height: b.height || 760,
...(Number.isFinite(b.x) && Number.isFinite(b.y) ? { x: b.x, y: b.y } : {}), ...(Number.isFinite(b.x) && Number.isFinite(b.y) ? { x: b.x, y: b.y } : {}),
title: entry.name, show: false, title: entry.name, show: false,
backgroundColor: entry.backgroundColor || (dark ? "#0b0e14" : "#ffffff"), backgroundColor: entry.backgroundColor || (dark ? "#0b0e14" : "#ffffff"),
icon: windowIcon(entry), icon: windowIcon(entry),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
}); });
windows.set(entry.key, w); windows.set(entry.key, w);
w.setMenuBarVisibility(false); w.setMenuBarVisibility(false);
if (process.platform === "win32") { if (process.platform === "win32") {
// Own taskbar identity: pinning the button pins the app, not Theseus, // Own taskbar identity: pinning the button pins the app, not Theseus,
// and relaunching from the pin comes back through --app=. // and relaunching from the pin comes back through --app=.
const { target, args } = launchSpec(entry); const { target, args } = launchSpec(entry);
try { w.setAppDetails({ appId: aumidFor(entry.key), appIconPath: icoPath(entry), appIconIndex: 0, relaunchCommand: `"${target}" ${args}`, relaunchDisplayName: entry.name }); } catch {} try { w.setAppDetails({ appId: aumidFor(entry.key), appIconPath: icoPath(entry), appIconIndex: 0, relaunchCommand: `"${target}" ${args}`, relaunchDisplayName: entry.name }); } catch {}
} }
const wc = w.webContents; const wc = w.webContents;
deps.prepareContents && deps.prepareContents(wc); deps.prepareContents && deps.prepareContents(wc);
// Page title in the title bar, suffixed with the app name unless the page // Page title in the title bar, suffixed with the app name unless the page
// already carries it (most do). // already carries it (most do).
wc.on("page-title-updated", (e, title) => { wc.on("page-title-updated", (e, title) => {
e.preventDefault(); e.preventDefault();
const t = String(title || "").trim(); const t = String(title || "").trim();
const has = t && (t.toLowerCase().includes(entry.shortName.toLowerCase()) || t.toLowerCase().includes(entry.name.toLowerCase())); 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 {} try { w.setTitle(!t ? entry.name : has ? t : `${t} — ${entry.shortName}`); } catch {}
}); });
// Cross-host navigations inside the window stay BCNR-first, like tabs. // Cross-host navigations inside the window stay BCNR-first, like tabs.
wc.on("will-navigate", (e, u) => { wc.on("will-navigate", (e, u) => {
try { try {
const p = new URL(u); const p = new URL(u);
if (p.protocol !== "http:" && p.protocol !== "https:") return; if (p.protocol !== "http:" && p.protocol !== "https:") return;
if (!deps.isBnsHost || !deps.isBnsHost(p.hostname)) return; if (!deps.isBnsHost || !deps.isBnsHost(p.hostname)) return;
let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {} let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {}
if (cur === p.hostname) return; if (cur === p.hostname) return;
e.preventDefault(); e.preventDefault();
deps.targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); }); deps.targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); });
} catch {} } catch {}
}); });
// Popups go to Theseus tabs — one place for tabs, and an app window has // Popups go to Theseus tabs — one place for tabs, and an app window has
// no tab strip to hold them. // no tab strip to hold them.
wc.setWindowOpenHandler(({ url }) => { wc.setWindowOpenHandler(({ url }) => {
if (url && url !== "about:blank") deps.openInTab(url); if (url && url !== "about:blank") deps.openInTab(url);
return { action: "deny" }; return { action: "deny" };
}); });
// No toolbar, so the keyboard carries navigation: Alt+←/→, F5, Ctrl+R. // No toolbar, so the keyboard carries navigation: Alt+←/→, F5, Ctrl+R.
wc.on("before-input-event", (e, input) => { wc.on("before-input-event", (e, input) => {
if (input.type !== "keyDown") return; if (input.type !== "keyDown") return;
const nav = wc.navigationHistory; const nav = wc.navigationHistory;
if (input.alt && input.key === "ArrowLeft" && nav.canGoBack()) { nav.goBack(); e.preventDefault(); } 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.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 === "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(); } 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) => { wc.on("context-menu", (_e, p) => {
const items = []; const items = [];
if (p.linkURL) { if (p.linkURL) {
items.push( items.push(
{ label: "Open link in Theseus", click: () => deps.openInTab(p.linkURL) }, { label: "Open link in Theseus", click: () => deps.openInTab(p.linkURL) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) }, { label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" }, { type: "separator" },
); );
} }
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { 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" }); else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
const nav = wc.navigationHistory; const nav = wc.navigationHistory;
items.push( items.push(
{ label: "Back", enabled: nav.canGoBack(), click: () => nav.goBack() }, { label: "Back", enabled: nav.canGoBack(), click: () => nav.goBack() },
{ label: "Forward", enabled: nav.canGoForward(), click: () => nav.goForward() }, { label: "Forward", enabled: nav.canGoForward(), click: () => nav.goForward() },
{ label: "Reload", click: () => wc.reload() }, { label: "Reload", click: () => wc.reload() },
{ type: "separator" }, { type: "separator" },
{ label: "Open this page in Theseus", click: () => deps.openInTab(wc.getURL()) }, { label: "Open this page in Theseus", click: () => deps.openInTab(wc.getURL()) },
{ label: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) }, { label: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) },
{ type: "separator" }, { type: "separator" },
{ label: `Remove ${entry.shortName} from Theseus…`, click: () => uninstall(entry.key, true) }, { label: `Remove ${entry.shortName} from Theseus…`, click: () => uninstall(entry.key, true) },
); );
Menu.buildFromTemplate(items).popup({ window: w }); Menu.buildFromTemplate(items).popup({ window: w });
}); });
const remember = () => { try { if (!w.isMinimized()) { entry.bounds = w.getNormalBounds(); save(); } } catch {} }; const remember = () => { try { if (!w.isMinimized()) { entry.bounds = w.getNormalBounds(); save(); } } catch {} };
w.on("resize", remember); w.on("move", remember); w.on("resize", remember); w.on("move", remember);
w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); }); w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); });
w.once("ready-to-show", () => { try { w.show(); } catch {} }); w.once("ready-to-show", () => { try { w.show(); } catch {} });
Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (urlOverride || entry.startUrl)) Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (urlOverride || entry.startUrl))
.then((t) => wc.loadURL(t || urlOverride || entry.startUrl)) .then((t) => wc.loadURL(t || urlOverride || entry.startUrl))
.catch(() => {}); .catch(() => {});
return w; return w;
} }
const openWindows = () => [...windows.values()].filter((w) => !w.isDestroyed()); const openWindows = () => [...windows.values()].filter((w) => !w.isDestroyed());
// ---- --app= launches -------------------------------------------------------------- // ---- --app= launches --------------------------------------------------------------
function appUrlFromArgv(argv) { function appUrlFromArgv(argv) {
for (const a of argv || []) { for (const a of argv || []) {
const m = /^--app=(.+)$/.exec(String(a)); const m = /^--app=(.+)$/.exec(String(a));
if (m && /^https?:\/\//i.test(m[1])) return m[1]; if (m && /^https?:\/\//i.test(m[1])) return m[1];
} }
return null; return null;
} }
// Open the installed app that owns `url` (start_url match first, then any // 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 // app whose scope contains it). A URL no app owns is returned as `null` so
// the caller can fall back to a normal tab. // the caller can fall back to a normal tab.
function launch(url) { function launch(url) {
const u = norm(url); const u = norm(url);
const entry = apps.find((a) => a.startUrl === u) || apps.find((a) => u.startsWith(a.scope)); const entry = apps.find((a) => a.startUrl === u) || apps.find((a) => u.startsWith(a.scope));
if (!entry) return null; if (!entry) return null;
return open(entry, entry.startUrl === u ? undefined : u); return open(entry, entry.startUrl === u ? undefined : u);
} }
module.exports = { init, list, find, probeTab, chipState, install, uninstall, open, openWindows, launch, appUrlFromArgv, describe }; module.exports = { init, list, find, probeTab, chipState, install, uninstall, open, openWindows, launch, appUrlFromArgv, describe };