diff --git a/home.html b/home.html
index cc89cd7c..97f8d2b7 100644
--- a/home.html
+++ b/home.html
@@ -327,7 +327,6 @@
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names." },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host." },
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace." },
- { title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "E2E-encrypted messaging over Nostr." },
{ title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web." },
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read .x and .bch names." },
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from the vault." },
diff --git a/lib/resolvers/handshake.cjs b/lib/resolvers/handshake.cjs
index be39bce3..46df2527 100644
--- a/lib/resolvers/handshake.cjs
+++ b/lib/resolvers/handshake.cjs
@@ -38,10 +38,20 @@ async function resolve(host, { endpoint, collision_policy } = {}) {
// Collision gate — only skip ICANN TLDs when icann-wins is set (the default).
const tld = name.split(".").pop();
if ((collision_policy || "icann-wins") === "icann-wins" && ICANN_TLDS.has(tld)) return null;
- // BNS TLDs are already handled by the normal BNS path upstream; HNS adapter
- // runs for every other dotted host. .eth / .bit go through their own adapters
- // first, so by the time we get here they've been eliminated.
- if (tld === "eth" || tld === "bit") return null;
+ // Other extra-resolver TLDs run before HNS in tryExtraResolvers — return
+ // null here so Handshake doesn't shadow them when their toggle is off and
+ // the user's intent was clearnet (e.g. a .crypto name that's not registered
+ // on UD should 404 clearnet, not get hijacked into HNS).
+ const CLAIMED = new Set([
+ "eth", "bit", "sol",
+ // Unstoppable Domains TLDs
+ "crypto","nft","wallet","bitcoin","blockchain","dao","888","polygon",
+ "klever","hi","kresus","anime","manga","go","unstoppable","pudgy",
+ "binanceus","austin","bitget","tball","secret","farms","raiin","stepn",
+ "emir","smokey","lfg","pog","witg","metropolis","wrkx","benji","altimist",
+ "ethermail",
+ ]);
+ if (CLAIMED.has(tld)) return null;
const cached = CACHE.get(name);
if (cached && cached.exp > Date.now()) return cached.result;
diff --git a/lib/resolvers/sns.cjs b/lib/resolvers/sns.cjs
new file mode 100644
index 00000000..4a9fbd59
--- /dev/null
+++ b/lib/resolvers/sns.cjs
@@ -0,0 +1,83 @@
+"use strict";
+// Solana Name Service resolver — .sol names via Bonfida's public SDK proxy.
+// SNS stores URL and IPFS records on-chain; the proxy exposes them as a
+// REST endpoint so Theseus doesn't need the Solana web3.js stack bundled in.
+//
+// Proxy responses (as of 2026-10):
+// GET /resolve/ → { s: "ok", result: "" }
+// or { s: "error", result: "..." }
+// GET /record//url → { s: "ok", result: "https://…" }
+// GET /record//ipfs → { s: "ok", result: "" }
+// GET /record//arwv → { s: "ok", result: "" }
+//
+// We prefer URL (clearnet redirect) over IPFS (gateway) over Arweave
+// (gateway) because URL is explicit intent and the user's gateway choice
+// shouldn't matter for a plain redirect.
+//
+// If the user's IP is Cloudflare-blocked for the default proxy, they can
+// paste any Solana RPC + sns-sdk-proxy compatible endpoint into Advanced.
+
+const POS_TTL_MS = 10 * 60 * 1000;
+const NEG_TTL_MS = 60 * 1000;
+const RPC_TIMEOUT_MS = 5000;
+const CACHE = new Map();
+
+async function proxyGet(base, path) {
+ const url = base.replace(/\/+$/, "") + path;
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(new Error("SNS proxy timeout")), RPC_TIMEOUT_MS);
+ try {
+ const res = await fetch(url, {
+ method: "GET",
+ headers: { "accept": "application/json", "user-agent": "Theseus/0.3.99 (silentmode.st)" },
+ signal: ctrl.signal,
+ });
+ if (!res.ok) throw new Error(`SNS HTTP ${res.status}`);
+ const json = await res.json();
+ if (json?.s === "ok" && json.result) return String(json.result);
+ return null; // no record / name not registered
+ } finally { clearTimeout(t); }
+}
+
+async function resolve(host, { rpc, gateway } = {}) {
+ if (!host) return null;
+ const name = String(host).toLowerCase();
+ if (!name.endsWith(".sol") || name === ".sol" || name === "sol") return null;
+ const cached = CACHE.get(name);
+ if (cached && cached.exp > Date.now()) return cached.result;
+
+ // "rpc" here is the SNS proxy base URL (not a Solana JSON-RPC); naming
+ // is for consistency with the other adapters.
+ const base = rpc || "https://sns-sdk-proxy.bonfida.workers.dev";
+ const gw = (gateway || "https://ipfs.io").replace(/\/+$/, "");
+ const bareName = name.replace(/\.sol$/i, "");
+
+ try {
+ // URL takes precedence (explicit redirect intent).
+ const urlRec = await proxyGet(base, `/record/${encodeURIComponent(bareName)}.sol/url`).catch(() => null);
+ if (urlRec) {
+ const u = /^https?:\/\//i.test(urlRec) ? urlRec : `https://${urlRec}`;
+ const result = { url: u, protocol: "sns", kind: "url" };
+ CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
+ return result;
+ }
+ // IPFS fallback.
+ const ipfsRec = await proxyGet(base, `/record/${encodeURIComponent(bareName)}.sol/ipfs`).catch(() => null);
+ if (ipfsRec) {
+ const result = { url: `${gw}/ipfs/${ipfsRec}`, protocol: "sns", kind: "ipfs", cid: ipfsRec };
+ CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
+ return result;
+ }
+ const result = { error: "no URL or IPFS record on this .sol name" };
+ CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
+ return result;
+ } catch (err) {
+ const result = { error: String(err?.message || err) };
+ CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
+ return result;
+ }
+}
+
+function clearCache() { CACHE.clear(); }
+
+module.exports = { resolve, clearCache };
diff --git a/lib/resolvers/unstoppable.cjs b/lib/resolvers/unstoppable.cjs
new file mode 100644
index 00000000..f40c3fba
--- /dev/null
+++ b/lib/resolvers/unstoppable.cjs
@@ -0,0 +1,95 @@
+"use strict";
+// Unstoppable Domains resolver — the whole UD TLD roster except .x, which
+// belongs to BNS/Sirius. "BNS wins on .x" is the design rule: a Sirius
+// registration must always resolve to the Sirius record, so this adapter
+// treats .x as not-ours and returns null, letting the chain fall through.
+//
+// Resolution: HTTPS GET to resolve.unstoppabledomains.com/domains/.
+// Response shape (as of 2026-10):
+// { records: {
+// "browser.redirect_url": "https://…",
+// "ipfs.html.value": "",
+// "dweb.ipfs.hash": "",
+// ...
+// },
+// meta: { owner, resolver, blockchain, networkId } }
+//
+// Precedence inside the record set: browser.redirect_url > ipfs.html.value
+// > dweb.ipfs.hash. Users who want a different gateway swap it in Advanced.
+
+const POS_TTL_MS = 10 * 60 * 1000;
+const NEG_TTL_MS = 60 * 1000;
+const RPC_TIMEOUT_MS = 5000;
+const CACHE = new Map();
+
+// TLDs on Unstoppable's registries (crypto + .nft + the Polygon-era batch).
+// Trimmed, not exhaustive — Unstoppable keeps adding new ones; a user whose
+// TLD isn't here can toggle Handshake "hns-wins" or add a per-host override
+// later. We explicitly skip .x — BNS owns .x in Theseus.
+const UD_TLDS = new Set([
+ "crypto", "nft", "wallet", "bitcoin", "blockchain", "dao",
+ "888", "polygon", "klever", "hi", "kresus", "anime", "manga",
+ "go", "unstoppable", "pudgy", "binanceus", "austin", "bitget",
+ "tball", "secret", "farms", "raiin", "stepn", "emir", "smokey",
+ "lfg", "pog", "witg", "metropolis", "wrkx", "benji", "altimist",
+ "ethermail",
+]);
+
+async function udFetch(base, name) {
+ const url = base.replace(/\/+$/, "") + "/domains/" + encodeURIComponent(name);
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(new Error("Unstoppable timeout")), RPC_TIMEOUT_MS);
+ try {
+ const res = await fetch(url, {
+ method: "GET",
+ headers: { "accept": "application/json", "user-agent": "Theseus/0.3.99 (silentmode.st)" },
+ signal: ctrl.signal,
+ });
+ if (!res.ok) throw new Error(`Unstoppable HTTP ${res.status}`);
+ return await res.json();
+ } finally { clearTimeout(t); }
+}
+
+async function resolve(host, { endpoint, gateway } = {}) {
+ if (!host || !host.includes(".")) return null;
+ const name = String(host).toLowerCase();
+ const tld = name.split(".").pop();
+ if (!UD_TLDS.has(tld)) return null; // not an Unstoppable TLD
+ // BNS owns .x — never shadow a Sirius registration with an Unstoppable one.
+ if (tld === "x") return null;
+
+ const cached = CACHE.get(name);
+ if (cached && cached.exp > Date.now()) return cached.result;
+
+ const base = endpoint || "https://resolve.unstoppabledomains.com";
+ const gw = (gateway || "https://ipfs.io").replace(/\/+$/, "");
+
+ try {
+ const data = await udFetch(base, name);
+ const records = data?.records || {};
+ const redirect = records["browser.redirect_url"];
+ if (redirect && typeof redirect === "string") {
+ const u = /^https?:\/\//i.test(redirect) ? redirect : `https://${redirect}`;
+ const result = { url: u, protocol: "unstoppable", kind: "redirect", target: u };
+ CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
+ return result;
+ }
+ const ipfs = records["ipfs.html.value"] || records["dweb.ipfs.hash"];
+ if (ipfs && typeof ipfs === "string") {
+ const result = { url: `${gw}/ipfs/${ipfs}`, protocol: "unstoppable", kind: "ipfs", cid: ipfs };
+ CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
+ return result;
+ }
+ const result = { error: "no browser.redirect_url or ipfs record" };
+ CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
+ return result;
+ } catch (err) {
+ const result = { error: String(err?.message || err) };
+ CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
+ return result;
+ }
+}
+
+function clearCache() { CACHE.clear(); }
+
+module.exports = { resolve, clearCache, UD_TLDS };
diff --git a/main.js b/main.js
index e26588b1..0f80d985 100644
--- a/main.js
+++ b/main.js
@@ -586,6 +586,7 @@ const SETTINGS_DEFAULTS = {
quickLinksShow: true,
quickLinks: [
{ id: "proton", url: "https://mail.proton.me/", title: "Proton Mail" },
+ { id: "hermes", url: "https://hermes.x/", title: "Hermes" },
{ id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" },
{ id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" },
{ id: "x", url: "https://x.com/", title: "X" },
@@ -674,7 +675,7 @@ function loadSettings() {
// itself). quickLinksOffered remembers it, so one the user removes stays
// removed. Kept out of SETTINGS_DEFAULTS so an old profile reads as "never offered".
try {
- const LATER_DEFAULTS = ["proton"]; // added in 0.3.88
+ const LATER_DEFAULTS = ["proton", "hermes"]; // proton 0.3.88 · hermes 0.3.99
const offered = Array.isArray(settings.quickLinksOffered) ? settings.quickLinksOffered.slice() : [];
const have = Array.isArray(settings.quickLinks) ? settings.quickLinks : null;
let changed = false;
@@ -682,8 +683,18 @@ function loadSettings() {
if (offered.includes(id)) continue;
offered.push(id); changed = true;
const def = SETTINGS_DEFAULTS.quickLinks.find((L) => L.id === id);
- // Proton Mail goes to the top of the web apps; any later one at the end.
- if (have && def && !have.some((L) => L && L.id === id)) { if (id === "proton") have.unshift({ ...def }); else have.push({ ...def }); }
+ // Proton Mail goes to the top of the web apps; Hermes slots in right
+ // after Proton (keeping Silent-Mode-owned messaging near the top); any
+ // other later one at the end.
+ if (have && def && !have.some((L) => L && L.id === id)) {
+ if (id === "proton") have.unshift({ ...def });
+ else if (id === "hermes") {
+ const after = have.findIndex((L) => L && L.id === "proton");
+ if (after >= 0) have.splice(after + 1, 0, { ...def });
+ else have.unshift({ ...def });
+ }
+ else have.push({ ...def });
+ }
}
if (changed) { settings.quickLinksOffered = offered; saveSettings(); }
} catch (e) { console.warn("quickLinks offer:", e?.message); }
@@ -889,8 +900,8 @@ const DEFAULT_EXTRA_RESOLVERS = {
ens: { enabled: false, rpc: "https://ethereum-rpc.publicnode.com", gateway: "https://ipfs.io" },
namecoin: { enabled: false, rpc: "" },
handshake: { enabled: false, endpoint: "https://hns.to", collision_policy: "icann-wins" },
- sns: { enabled: false, rpc: "https://api.mainnet-beta.solana.com" },
- unstoppable: { enabled: false, endpoint: "https://resolve.unstoppabledomains.com" },
+ sns: { enabled: false, rpc: "https://sns-sdk-proxy.bonfida.workers.dev", gateway: "https://ipfs.io" },
+ unstoppable: { enabled: false, endpoint: "https://resolve.unstoppabledomains.com", gateway: "https://ipfs.io" },
},
ca_bundles: {
ru_national: { enabled: false, scope_tlds: ["ru", "рф", "gov.ru", "online.ru"] },
@@ -5819,7 +5830,34 @@ async function tryExtraResolvers(t, id, host, rest) {
} catch (e) { console.warn("[namecoin] resolve failed:", e?.message); }
}
+ // SNS — .sol → Bonfida SDK proxy → URL/IPFS record on Solana.
+ if (h.endsWith(".sol") && resolvers.sns?.enabled) {
+ try {
+ const sns = require("./lib/resolvers/sns.cjs");
+ const result = await sns.resolve(h, {
+ rpc: resolvers.sns.rpc,
+ gateway: resolvers.sns.gateway,
+ });
+ if (result && result.url) return navigateToExtra(result, "sns", { kind: result.kind, cid: result.cid });
+ } catch (e) { console.warn("[sns] resolve failed:", e?.message); }
+ }
+
+ // Unstoppable Domains — the UD TLD set (except .x, which belongs to BNS).
+ if (resolvers.unstoppable?.enabled) {
+ try {
+ const ud = require("./lib/resolvers/unstoppable.cjs");
+ const result = await ud.resolve(h, {
+ endpoint: resolvers.unstoppable.endpoint,
+ gateway: resolvers.unstoppable.gateway,
+ });
+ if (result && result.url) return navigateToExtra(result, "unstoppable", { kind: result.kind, cid: result.cid, target: result.target });
+ } catch (e) { console.warn("[unstoppable] resolve failed:", e?.message); }
+ }
+
// Handshake — route non-ICANN TLDs through hns.to (or configured gateway).
+ // Runs last because it has the broadest match (every non-ICANN TLD) and we
+ // want the more-specific adapters (ENS .eth, Namecoin .bit, SNS .sol,
+ // Unstoppable's own TLD list) to win over the generic HNS gateway.
if (resolvers.handshake?.enabled) {
try {
const hns = require("./lib/resolvers/handshake.cjs");
diff --git a/package.json b/package.json
index 1dcc02df..e329cea0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
- "version": "0.3.98",
+ "version": "0.3.99",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
diff --git a/settings.html b/settings.html
index 8fc1c5db..e128fed5 100644
--- a/settings.html
+++ b/settings.html
@@ -520,9 +520,10 @@
Solana Name Service (SNS)
-
.sol → owner record from the Solana name registry. Not wired yet — coming in 0.3.98.
+
.sol → URL or IPFS record on the Solana name registry, read through Bonfida's public SDK proxy. The proxy sits behind Cloudflare; if your network is blocked, paste an alternative in Advanced.
Advanced
-
+
+
@@ -531,10 +532,11 @@
Unstoppable Domains
-
.crypto, .nft, .wallet, .bitcoin, .blockchain, .dao, .888, .polygon, .unstoppable, and more.
- ⚠ .x also exists on Unstoppable — BNS wins on .x, so a Sirius registration always takes precedence. Not wired yet — coming in 0.3.98.
+
.crypto, .nft, .wallet, .bitcoin, .blockchain, .dao, .888, .polygon, .unstoppable, and more. Follows browser.redirect_url first, then IPFS records.
+ ⚠ .x also exists on Unstoppable — BNS wins on .x, so a Sirius registration always takes precedence (this adapter skips .x entirely).