mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
On a cold login / permission cache-miss, requestEnablePermissions probes several permission-payload variants before the no-arg prompt. The specific (silent) variants were capped at 3s "to fail fast" — but real extensions REJECT an unsupported payload shape almost immediately (and a fast rejection already falls through to the next variant), so the 3s cap added no real safety. What it DID do: kill a slow-but-responsive signer mid-grant, forcing all 3 specific variants to time out (3s each) before the prompt variant finally succeeded — ~25s of dead time before signer-ready. Raise the specific-variant timeout to 20s (SPECIFIC_VARIANT_TIMEOUT_MS). The bound now only matters for a variant that genuinely HANGS; unsupported shapes still reject fast, and a slow signer can actually answer the first supported variant. Channel-sim at 16s/call: enable extension calls 4 → 1, signer-ready ~42s → ~33s, lists-sync ~58s → ~50s; healthy login unchanged (~1.6s), all lists still decrypt. Full unit suite green (pre-existing Node localStorage failures unrelated). KNOWN_BUGS #0. Co-Authored-By: Claude Opus 4.8 <[email protected]>
731 lines
23 KiB
JavaScript
731 lines
23 KiB
JavaScript
import { devLogger, userLogger } from "../utils/logger.js";
|
|
import { SHORT_TIMEOUT_MS } from "../constants.js";
|
|
|
|
export const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds
|
|
export const NIP07_EXTENSION_WAIT_TIMEOUT_MS = SHORT_TIMEOUT_MS;
|
|
export const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
|
|
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.";
|
|
const NIP07_PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions";
|
|
|
|
// Give the NIP-07 extension enough time to surface its approval prompt and let
|
|
// users unlock/authorize it. Seven seconds proved too aggressive once vendors
|
|
// started requiring an unlock step, so we extend the window substantially while
|
|
// still allowing manual overrides via __BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__.
|
|
const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 45_000;
|
|
|
|
// Timeout for the SPECIFIC (silent, payload-carrying) permission variants we
|
|
// probe before the no-arg prompt. This is NOT a "fail fast" budget: real
|
|
// extensions REJECT a payload shape they don't support almost immediately, and
|
|
// a fast rejection moves us to the next variant right away. The timeout only
|
|
// bounds a variant that HANGS — so a tiny 3s cap added no real safety against
|
|
// unsupported variants but DID kill slow-but-responsive signers mid-grant,
|
|
// forcing 3 wasted attempts before the prompt variant succeeded (~25s of dead
|
|
// time on a 16s/call signer in the channel-sim). Give it real room.
|
|
const SPECIFIC_VARIANT_TIMEOUT_MS = 20_000;
|
|
|
|
export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([
|
|
// Encryption helpers — request both legacy NIP-04 and modern NIP-44 upfront
|
|
"nip04.encrypt",
|
|
"nip04.decrypt",
|
|
"nip44.encrypt",
|
|
"nip44.decrypt",
|
|
"nip44.v2.encrypt",
|
|
"nip44.v2.decrypt",
|
|
]);
|
|
|
|
export const DEFAULT_NIP07_CORE_METHODS = Object.freeze([
|
|
// Core auth + relay metadata
|
|
"get_public_key",
|
|
"sign_event",
|
|
]);
|
|
|
|
export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
|
|
...DEFAULT_NIP07_CORE_METHODS,
|
|
...DEFAULT_NIP07_ENCRYPTION_METHODS,
|
|
]);
|
|
|
|
export const NIP07_PRIORITY = Object.freeze({
|
|
// CRITICAL is reserved for establishing the signer channel itself (the
|
|
// permission/enable handshake behind the readiness gate). It must outrank
|
|
// list decryption (HIGH): otherwise a flood of HIGH-priority decrypts jumps
|
|
// ahead of the gate in the queue and starves it, so the channel never gets
|
|
// warmed and every decrypt keeps timing out (the real-env ~48s "signer-ready"
|
|
// — KNOWN_BUGS #0).
|
|
CRITICAL: 20,
|
|
HIGH: 10,
|
|
NORMAL: 5,
|
|
LOW: 1,
|
|
});
|
|
|
|
class Nip07RequestQueue {
|
|
// `reservedForForeground` slots are kept available for non-background
|
|
// (priority > LOW) work, so a flood of LOW-priority background decrypts
|
|
// (DM backfill, watch-history months) can never occupy every slot and
|
|
// starve the feed-driving lists. Background work is therefore capped at
|
|
// `maxConcurrent - reservedForForeground` concurrent slots (min 1).
|
|
constructor(maxConcurrent = 2, reservedForForeground = 1) {
|
|
this.queue = [];
|
|
this.maxConcurrent =
|
|
Number.isFinite(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 2;
|
|
const reserved =
|
|
Number.isFinite(reservedForForeground) && reservedForForeground >= 0
|
|
? Math.floor(reservedForForeground)
|
|
: 1;
|
|
// Never reserve so much that background work could never run.
|
|
this.backgroundConcurrencyCap = Math.max(1, this.maxConcurrent - reserved);
|
|
this.activeCount = 0;
|
|
this.backgroundActiveCount = 0;
|
|
}
|
|
|
|
isBackground(priority) {
|
|
return Number.isFinite(priority) && priority <= NIP07_PRIORITY.LOW;
|
|
}
|
|
|
|
enqueue(task, priority = NIP07_PRIORITY.NORMAL) {
|
|
return new Promise((resolve, reject) => {
|
|
this.queue.push({
|
|
task,
|
|
priority: Number.isFinite(priority) ? priority : NIP07_PRIORITY.NORMAL,
|
|
resolve,
|
|
reject,
|
|
addedAt: Date.now(),
|
|
});
|
|
// Sort by priority (descending), then by insertion time (ascending) for fairness
|
|
this.queue.sort((a, b) => {
|
|
if (b.priority !== a.priority) {
|
|
return b.priority - a.priority;
|
|
}
|
|
return a.addedAt - b.addedAt;
|
|
});
|
|
this.process();
|
|
});
|
|
}
|
|
|
|
async process() {
|
|
if (this.activeCount >= this.maxConcurrent) return;
|
|
|
|
while (this.activeCount < this.maxConcurrent && this.queue.length > 0) {
|
|
// Peek the highest-priority item. The queue is sorted priority-desc, so
|
|
// if the head is background-class and the background cap is already
|
|
// saturated, every remaining item is background too — stop, keeping the
|
|
// free slot(s) reserved for any foreground work that arrives later.
|
|
const head = this.queue[0];
|
|
const headIsBackground = this.isBackground(head.priority);
|
|
if (
|
|
headIsBackground &&
|
|
this.backgroundActiveCount >= this.backgroundConcurrencyCap
|
|
) {
|
|
break;
|
|
}
|
|
|
|
const item = this.queue.shift();
|
|
if (!item) break;
|
|
|
|
const itemIsBackground = this.isBackground(item.priority);
|
|
this.activeCount++;
|
|
if (itemIsBackground) {
|
|
this.backgroundActiveCount++;
|
|
}
|
|
const { task, resolve, reject } = item;
|
|
|
|
// We don't await the task here to allow parallelism loop to continue
|
|
Promise.resolve()
|
|
.then(() => task())
|
|
.then((result) => {
|
|
resolve(result);
|
|
})
|
|
.catch((error) => {
|
|
reject(error);
|
|
})
|
|
.finally(() => {
|
|
this.activeCount--;
|
|
if (itemIsBackground) {
|
|
this.backgroundActiveCount--;
|
|
}
|
|
this.process();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Module-level priority queue to serialize NIP-07 extension requests.
|
|
// This prevents "message channel closed" errors caused by race conditions
|
|
// when multiple components (blocks, DMs, auth) query the extension simultaneously,
|
|
// while allowing critical tasks (e.g. blocklist decryption) to jump the line.
|
|
// Stability: keep extension request concurrency conservative. Some NIP-07
|
|
// providers/content scripts drop channels under high parallel load, which
|
|
// manifests as "message channel closed" and repeated decrypt timeouts.
|
|
const requestQueue = new Nip07RequestQueue(2);
|
|
|
|
export const NIP07_CHANNEL_UNRESPONSIVE_ERROR_MESSAGE =
|
|
"The NIP-07 signer channel is unresponsive. It usually recovers shortly; if it persists, refresh the page or re-open your extension.";
|
|
|
|
// Circuit breaker for a dead/unresponsive nip-07 message channel.
|
|
//
|
|
// Real-env failure mode (KNOWN_BUGS #0): under the post-login burst the
|
|
// extension's single content-script message port drops ("message channel
|
|
// closed"). After that EVERY decrypt call hangs to its full timeout (~15s).
|
|
// Each list service (blocks, hashtags, subscriptions, watch history)
|
|
// independently retries on that timeout, so the app sits in a permanent loop of
|
|
// 15s-blocking extension calls — pinning the CPU ("fans at max") and never
|
|
// recovering until a manual page refresh.
|
|
//
|
|
// The breaker converts that into: a few timeouts OPEN the circuit, after which
|
|
// calls fail FAST (no 15s wait) so the retry loops become cheap and the CPU
|
|
// settles. While open, a single periodic PROBE is allowed through to detect
|
|
// recovery; one success CLOSES the circuit and normal decryption resumes — no
|
|
// refresh required. Interactive permission prompts bypass the breaker so a user
|
|
// taking their time at the extension UI can never trip or be blocked by it.
|
|
const CIRCUIT_TIMEOUT_THRESHOLD = 3; // consecutive timeouts before opening
|
|
const CIRCUIT_OPEN_MS = 30_000; // how long the circuit stays open
|
|
const CIRCUIT_PROBE_INTERVAL_MS = 8_000; // min spacing between recovery probes
|
|
const channelBreaker = {
|
|
consecutiveTimeouts: 0,
|
|
openUntil: 0,
|
|
lastProbeAt: 0,
|
|
};
|
|
|
|
export function getNip07ChannelBreakerState() {
|
|
const now = Date.now();
|
|
return {
|
|
open: channelBreaker.openUntil > now,
|
|
consecutiveTimeouts: channelBreaker.consecutiveTimeouts,
|
|
openUntil: channelBreaker.openUntil,
|
|
};
|
|
}
|
|
|
|
export function resetNip07ChannelBreaker() {
|
|
channelBreaker.consecutiveTimeouts = 0;
|
|
channelBreaker.openUntil = 0;
|
|
channelBreaker.lastProbeAt = 0;
|
|
}
|
|
|
|
function isCircuitOpen(now) {
|
|
return channelBreaker.openUntil > now;
|
|
}
|
|
|
|
// While open, allow exactly one probe per CIRCUIT_PROBE_INTERVAL_MS so we can
|
|
// detect recovery without re-flooding the channel.
|
|
function claimProbeSlot(now) {
|
|
if (now - channelBreaker.lastProbeAt >= CIRCUIT_PROBE_INTERVAL_MS) {
|
|
channelBreaker.lastProbeAt = now;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function recordChannelSuccess() {
|
|
const wasOpen = channelBreaker.openUntil > Date.now();
|
|
channelBreaker.consecutiveTimeouts = 0;
|
|
channelBreaker.openUntil = 0;
|
|
if (wasOpen) {
|
|
userLogger.info(
|
|
"[nostr] NIP-07 signer channel recovered; resuming normal requests.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function recordChannelTimeout() {
|
|
channelBreaker.consecutiveTimeouts += 1;
|
|
if (
|
|
channelBreaker.consecutiveTimeouts >= CIRCUIT_TIMEOUT_THRESHOLD &&
|
|
channelBreaker.openUntil <= Date.now()
|
|
) {
|
|
channelBreaker.openUntil = Date.now() + CIRCUIT_OPEN_MS;
|
|
userLogger.warn(
|
|
`[nostr] NIP-07 signer channel unresponsive after ${channelBreaker.consecutiveTimeouts} timeouts; ` +
|
|
`failing fast for ${CIRCUIT_OPEN_MS / 1000}s and probing for recovery.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Some non-timeout failures are actually dead-channel signals, NOT "the
|
|
// extension answered with an error." When the content-script message port is
|
|
// severed the call rejects synchronously with one of these messages — which is
|
|
// the very condition the breaker exists for, so they must count TOWARD opening
|
|
// the circuit (not reset it). Matching the real-env "message channel closed"
|
|
// case (KNOWN_BUGS #0) is the whole point.
|
|
const CHANNEL_DEATH_PATTERNS = [
|
|
"message channel closed",
|
|
"could not establish connection",
|
|
"receiving end does not exist",
|
|
"extension context invalidated",
|
|
"connection lost",
|
|
];
|
|
function isChannelDeathError(error) {
|
|
const message =
|
|
error && typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
if (!message) return false;
|
|
return CHANNEL_DEATH_PATTERNS.some((pattern) => message.includes(pattern));
|
|
}
|
|
|
|
// A non-timeout failure where the extension genuinely RESPONDED (e.g. user
|
|
// rejection, "permission denied", unsupported method) means the channel is
|
|
// alive — clear the timeout streak so we don't open on unrelated errors.
|
|
function recordChannelResponsiveFailure() {
|
|
channelBreaker.consecutiveTimeouts = 0;
|
|
}
|
|
|
|
export function getEnableVariantTimeoutMs() {
|
|
const overrideValue =
|
|
typeof globalThis !== "undefined" &&
|
|
globalThis !== null &&
|
|
Number.isFinite(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
|
|
? Math.floor(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
|
|
: null;
|
|
|
|
if (overrideValue !== null && overrideValue > 0) {
|
|
return Math.max(50, overrideValue);
|
|
}
|
|
|
|
return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS;
|
|
}
|
|
|
|
export function normalizePermissionMethod(method) {
|
|
return typeof method === "string" && method.trim() ? method.trim() : "";
|
|
}
|
|
|
|
function getNip07PermissionStorage() {
|
|
const scope =
|
|
typeof globalThis !== "undefined" && globalThis ? globalThis : undefined;
|
|
const browserWindow =
|
|
typeof window !== "undefined" && window ? window : undefined;
|
|
|
|
if (browserWindow?.localStorage) {
|
|
return browserWindow.localStorage;
|
|
}
|
|
|
|
if (scope?.localStorage) {
|
|
return scope.localStorage;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function readStoredNip07Permissions() {
|
|
const storage = getNip07PermissionStorage();
|
|
if (!storage) {
|
|
return new Set();
|
|
}
|
|
|
|
let rawValue = null;
|
|
try {
|
|
rawValue = storage.getItem(NIP07_PERMISSIONS_STORAGE_KEY);
|
|
} catch (error) {
|
|
return new Set();
|
|
}
|
|
|
|
if (!rawValue) {
|
|
return new Set();
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawValue);
|
|
const storedMethods = Array.isArray(parsed)
|
|
? parsed
|
|
: Array.isArray(parsed?.grantedMethods)
|
|
? parsed.grantedMethods
|
|
: Array.isArray(parsed?.methods)
|
|
? parsed.methods
|
|
: [];
|
|
return new Set(
|
|
storedMethods
|
|
.map((method) => normalizePermissionMethod(method))
|
|
.filter(Boolean),
|
|
);
|
|
} catch (error) {
|
|
clearStoredNip07Permissions();
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
export function writeStoredNip07Permissions(methods) {
|
|
const storage = getNip07PermissionStorage();
|
|
if (!storage) {
|
|
return;
|
|
}
|
|
|
|
const normalized = Array.from(
|
|
new Set(
|
|
Array.from(methods || [])
|
|
.map((method) => normalizePermissionMethod(method))
|
|
.filter(Boolean),
|
|
),
|
|
);
|
|
|
|
try {
|
|
if (!normalized.length) {
|
|
storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY);
|
|
return;
|
|
}
|
|
|
|
storage.setItem(
|
|
NIP07_PERMISSIONS_STORAGE_KEY,
|
|
JSON.stringify({ grantedMethods: normalized }),
|
|
);
|
|
} catch (error) {
|
|
// ignore persistence failures
|
|
}
|
|
}
|
|
|
|
export function clearStoredNip07Permissions() {
|
|
const storage = getNip07PermissionStorage();
|
|
if (!storage) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY);
|
|
} catch (error) {
|
|
// ignore cleanup issues
|
|
}
|
|
}
|
|
|
|
export function withNip07Timeout(
|
|
operation,
|
|
{
|
|
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
|
|
message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
|
|
} = {},
|
|
) {
|
|
const numericTimeout = Number(timeoutMs);
|
|
const effectiveTimeout =
|
|
Number.isFinite(numericTimeout) && numericTimeout > 0
|
|
? numericTimeout
|
|
: NIP07_LOGIN_TIMEOUT_MS;
|
|
|
|
let timeoutId;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
reject(new Error(message));
|
|
}, effectiveTimeout);
|
|
});
|
|
|
|
let operationResult;
|
|
try {
|
|
operationResult = operation();
|
|
} catch (err) {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const operationPromise = Promise.resolve(operationResult);
|
|
|
|
return Promise.race([operationPromise, timeoutPromise]).finally(() => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function runNip07WithRetry(
|
|
operation,
|
|
{
|
|
label = "NIP-07 operation",
|
|
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
|
|
retryMultiplier = 2,
|
|
priority = NIP07_PRIORITY.NORMAL,
|
|
// Interactive permission prompts bypass the circuit breaker: a user taking
|
|
// their time at the extension UI is not a dead channel, and the prompt must
|
|
// always be allowed through (and can itself heal the channel).
|
|
bypassCircuitBreaker = false,
|
|
} = {},
|
|
) {
|
|
// We wrap the entire retry logic in a queue task to ensure only one
|
|
// request hits the extension at a time (per slot).
|
|
const executeTask = async () => {
|
|
// Circuit breaker: when the channel has been declared unresponsive, fail
|
|
// fast (no multi-second hang) for everything except a single periodic probe
|
|
// — this stops the per-list retry loops from pinning the CPU on a dead
|
|
// channel, while still detecting recovery without a page refresh.
|
|
let isProbe = false;
|
|
if (!bypassCircuitBreaker) {
|
|
const now = Date.now();
|
|
if (isCircuitOpen(now)) {
|
|
if (claimProbeSlot(now)) {
|
|
isProbe = true;
|
|
} else {
|
|
const fastFail = new Error(
|
|
NIP07_CHANNEL_UNRESPONSIVE_ERROR_MESSAGE,
|
|
);
|
|
fastFail.code = "nip07-channel-unresponsive";
|
|
throw fastFail;
|
|
}
|
|
}
|
|
}
|
|
|
|
// We wrap the operation invocation to ensure it returns a fresh promise each time
|
|
// we attempt it. This is critical for retries: if the first attempt stalls (dropped
|
|
// by extension), re-awaiting the same promise would just keep waiting on the dead request.
|
|
const invokeOperation = () => Promise.resolve(operation());
|
|
|
|
const onResult = (result) => {
|
|
if (!bypassCircuitBreaker) {
|
|
recordChannelSuccess();
|
|
}
|
|
return result;
|
|
};
|
|
const onTimeout = () => {
|
|
if (!bypassCircuitBreaker) {
|
|
recordChannelTimeout();
|
|
}
|
|
};
|
|
const onResponsiveFailure = () => {
|
|
if (!bypassCircuitBreaker) {
|
|
recordChannelResponsiveFailure();
|
|
}
|
|
};
|
|
|
|
try {
|
|
const result = await withNip07Timeout(invokeOperation, {
|
|
timeoutMs,
|
|
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
|
|
});
|
|
return onResult(result);
|
|
} catch (error) {
|
|
const isTimeoutError =
|
|
error instanceof Error &&
|
|
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
|
|
|
|
if (!isTimeoutError) {
|
|
// A severed message port rejects with a channel-death message — that IS
|
|
// the dead-channel condition, so count it toward opening the circuit.
|
|
// Any other error means the extension actually responded (channel alive).
|
|
if (isChannelDeathError(error)) {
|
|
onTimeout();
|
|
} else {
|
|
onResponsiveFailure();
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// A probe that times out leaves the circuit open; don't waste a longer
|
|
// retry on a channel we already believe is dead.
|
|
if (retryMultiplier <= 1 || isProbe) {
|
|
onTimeout();
|
|
throw error;
|
|
}
|
|
|
|
const extendedTimeout = Math.max(
|
|
timeoutMs,
|
|
Math.round(timeoutMs * retryMultiplier),
|
|
);
|
|
|
|
devLogger.warn(
|
|
`[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`,
|
|
);
|
|
|
|
// On retry, we invoke operation() again to send a fresh request to the extension.
|
|
try {
|
|
const result = await withNip07Timeout(invokeOperation, {
|
|
timeoutMs: extendedTimeout,
|
|
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
|
|
});
|
|
return onResult(result);
|
|
} catch (retryError) {
|
|
const retryTimedOut =
|
|
retryError instanceof Error &&
|
|
retryError.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
|
|
if (retryTimedOut || isChannelDeathError(retryError)) {
|
|
onTimeout();
|
|
} else {
|
|
onResponsiveFailure();
|
|
}
|
|
throw retryError;
|
|
}
|
|
}
|
|
};
|
|
|
|
return requestQueue.enqueue(executeTask, priority);
|
|
}
|
|
|
|
export async function requestEnablePermissions(
|
|
extension,
|
|
outstandingMethods,
|
|
{ isDevMode = false } = {},
|
|
) {
|
|
if (!extension) {
|
|
return { ok: false, error: new Error("extension-unavailable") };
|
|
}
|
|
|
|
const hasRequestPermissions = typeof extension.requestPermissions === "function";
|
|
const hasEnable = typeof extension.enable === "function";
|
|
|
|
const normalized = Array.isArray(outstandingMethods)
|
|
? outstandingMethods
|
|
.map((method) => normalizePermissionMethod(method))
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const permissionVariants = (() => {
|
|
const variants = [];
|
|
if (normalized.length) {
|
|
variants.push({
|
|
permissions: normalized.map((method) => ({ method })),
|
|
});
|
|
variants.push({ permissions: normalized });
|
|
variants.push(normalized);
|
|
}
|
|
variants.push(null);
|
|
return variants;
|
|
})();
|
|
|
|
if (!hasRequestPermissions && !hasEnable) {
|
|
return { ok: true, code: "enable-unavailable" };
|
|
}
|
|
|
|
let lastError = null;
|
|
const runPermissionMethod = async (methodName, method) => {
|
|
for (const options of permissionVariants) {
|
|
// Specific (silent) payload variants get a generous bound so a slow signer
|
|
// can actually answer the FIRST supported one (unsupported shapes still
|
|
// reject fast and fall through immediately). The no-arg prompt variant
|
|
// gets the full window so users have time to confirm in the extension UI.
|
|
const variantTimeoutOverrides = options
|
|
? {
|
|
timeoutMs: Math.min(
|
|
SPECIFIC_VARIANT_TIMEOUT_MS,
|
|
getEnableVariantTimeoutMs(),
|
|
),
|
|
retryMultiplier: 1,
|
|
}
|
|
: {
|
|
timeoutMs: Math.min(
|
|
NIP07_LOGIN_TIMEOUT_MS,
|
|
getEnableVariantTimeoutMs(),
|
|
),
|
|
retryMultiplier: 1,
|
|
};
|
|
|
|
try {
|
|
await runNip07WithRetry(
|
|
() =>
|
|
options === null
|
|
? method()
|
|
: methodName === "enable"
|
|
? method(options)
|
|
: method(options),
|
|
{
|
|
label: `extension.${methodName}`,
|
|
...variantTimeoutOverrides,
|
|
// Interactive grant — never gated by (and can heal) the breaker.
|
|
bypassCircuitBreaker: true,
|
|
// Establishing the channel must outrank list decryption so the
|
|
// handshake isn't starved by a flood of HIGH-priority decrypts.
|
|
priority: NIP07_PRIORITY.CRITICAL,
|
|
},
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (options && isDevMode) {
|
|
userLogger.warn(
|
|
`[nostr] extension.${methodName} permission request failed:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Compatibility-first: prefer standard NIP-07 enable() where available.
|
|
if (hasEnable) {
|
|
const enableSucceeded = await runPermissionMethod(
|
|
"enable",
|
|
extension.enable.bind(extension),
|
|
);
|
|
if (enableSucceeded) {
|
|
return { ok: true };
|
|
}
|
|
}
|
|
|
|
// Fallback for extensions that implement requestPermissions() but not enable(),
|
|
// or where enable() payload handling is broken.
|
|
if (hasRequestPermissions) {
|
|
const requestPermissionsSucceeded = await runPermissionMethod(
|
|
"requestPermissions",
|
|
extension.requestPermissions.bind(extension),
|
|
);
|
|
if (requestPermissionsSucceeded) {
|
|
return { ok: true };
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
error: lastError || new Error("permission-denied"),
|
|
};
|
|
}
|
|
|
|
export const __testExports = {
|
|
requestEnablePermissions,
|
|
runNip07WithRetry,
|
|
withNip07Timeout,
|
|
getEnableVariantTimeoutMs,
|
|
readStoredNip07Permissions,
|
|
writeStoredNip07Permissions,
|
|
clearStoredNip07Permissions,
|
|
normalizePermissionMethod,
|
|
getNip07ChannelBreakerState,
|
|
resetNip07ChannelBreaker,
|
|
CIRCUIT_TIMEOUT_THRESHOLD,
|
|
CIRCUIT_OPEN_MS,
|
|
CIRCUIT_PROBE_INTERVAL_MS,
|
|
};
|
|
|
|
export function waitForNip07Extension(timeoutMs = NIP07_EXTENSION_WAIT_TIMEOUT_MS) {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof window !== "undefined" && window.nostr) {
|
|
resolve(window.nostr);
|
|
return;
|
|
}
|
|
|
|
if (typeof window === "undefined") {
|
|
reject(new Error("Not running in a browser environment."));
|
|
return;
|
|
}
|
|
|
|
const start = Date.now();
|
|
let resolved = false;
|
|
|
|
const finish = (ext) => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
clearInterval(interval);
|
|
resolve(ext);
|
|
};
|
|
|
|
const fail = () => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
clearInterval(interval);
|
|
reject(new Error("Nostr extension not found within timeout."));
|
|
};
|
|
|
|
// Poll every 50ms for window.nostr injection.
|
|
const interval = setInterval(() => {
|
|
if (window.nostr) {
|
|
finish(window.nostr);
|
|
} else if (Date.now() - start > timeoutMs) {
|
|
fail();
|
|
}
|
|
}, 50);
|
|
|
|
// Additionally listen for the DOMContentLoaded event — many extensions
|
|
// inject window.nostr during this phase, and detecting it via the event
|
|
// can be faster than waiting for the next poll tick.
|
|
if (typeof document !== "undefined" && document.readyState === "loading") {
|
|
const onReady = () => {
|
|
document.removeEventListener("DOMContentLoaded", onReady);
|
|
if (window.nostr) {
|
|
finish(window.nostr);
|
|
}
|
|
};
|
|
document.addEventListener("DOMContentLoaded", onReady);
|
|
}
|
|
});
|
|
}
|