564 lines
21 KiB
JavaScript
564 lines
21 KiB
JavaScript
// Browser state: identity, pins, contacts and read markers in localStorage;
|
|
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
|
|
// is plaintext (the master secret excepted — it is the user's browser profile).
|
|
|
|
import { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js";
|
|
import { ID_LEN, MAX_CHAIN, b32decode, b32encode, identityFromSeed, identitySeed, tokenFor } from "./proto.js";
|
|
|
|
const KEY = "gsmol";
|
|
|
|
export const TIER_MAIN = 0, TIER_REQUESTS = 1;
|
|
|
|
// Every getter reads the whole blob and some are called per message row, so the
|
|
// parse is cached. `derived` holds the identities that follow from it, which
|
|
// cost an Ed25519 scalar multiplication each.
|
|
let cached = null, derived = null;
|
|
|
|
function load() {
|
|
if (cached) return cached;
|
|
try {
|
|
cached = JSON.parse(localStorage.getItem(KEY) || "{}");
|
|
} catch {
|
|
cached = {};
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
function save(state) {
|
|
localStorage.setItem(KEY, JSON.stringify(state));
|
|
cached = state;
|
|
derived = null;
|
|
}
|
|
|
|
// Another tab writing this key leaves our copy stale.
|
|
window.addEventListener("storage", (event) => {
|
|
if (event.key === KEY || event.key === null) cached = derived = null;
|
|
});
|
|
|
|
function update(fn) {
|
|
const state = load();
|
|
const next = fn(state);
|
|
save(next ?? state);
|
|
}
|
|
|
|
// --- identity (§2) -------------------------------------------------------------
|
|
|
|
export function master() {
|
|
const raw = load().master;
|
|
return raw ? unhex(raw) : null;
|
|
}
|
|
|
|
// The rotation index (§7) of the identity currently in use.
|
|
export function rotations() {
|
|
return load().rotations ?? 0;
|
|
}
|
|
|
|
export function identity() {
|
|
return identities()[0] ?? null;
|
|
}
|
|
|
|
// §7: every key rotated away from is re-derivable from the master, since mail
|
|
// sealed to a superseded key is readable with nothing else.
|
|
export function identities() {
|
|
if (derived) return derived;
|
|
const m = master();
|
|
if (!m) return [];
|
|
derived = [];
|
|
for (let n = rotations(); n >= 0; n--) derived.push(identityFromSeed(identitySeed(m, n)));
|
|
return derived;
|
|
}
|
|
|
|
function bindMaster(newMaster, rotationIndex, syncOk) {
|
|
if (master()) throw new Error("an identity already exists; rotate it instead");
|
|
update(state => {
|
|
state.master = hex(newMaster);
|
|
state.rotations = rotationIndex;
|
|
state.syncOk = syncOk;
|
|
});
|
|
setCursor(0, new Uint8Array(ID_LEN));
|
|
}
|
|
|
|
// A fresh identity: rotation index 0, and an empty accepted set is already
|
|
// complete, so it may sync.
|
|
export function setIdentity(newMaster) {
|
|
bindMaster(newMaster, 0, true);
|
|
}
|
|
|
|
// §2: recovering a master alone does not recover which correspondents were
|
|
// accepted, so that set must not overwrite the server's until rebuilt.
|
|
export function restoreMaster(newMaster, rotationIndex = 0) {
|
|
bindMaster(newMaster, rotationIndex, false);
|
|
}
|
|
|
|
// Corrects the rotation index once a RESOLVE against the account's address
|
|
// reveals which one the server actually has bound (see restoreMaster()'s
|
|
// default of 0, the common case of a never-rotated identity).
|
|
export function setRotationIndex(n) {
|
|
update(state => { state.rotations = n; });
|
|
}
|
|
|
|
// Whether the accepted-correspondent set held here may replace the server's
|
|
// on the next AUTH — false right after a restore from the master alone, whose
|
|
// empty set must not erase the server's (§4).
|
|
export function syncOk() {
|
|
return load().syncOk ?? true;
|
|
}
|
|
|
|
export function setSyncOk(ok) {
|
|
update(state => { state.syncOk = ok; });
|
|
}
|
|
|
|
// Rotation (§7): only the index advances; the superseded key stays derivable
|
|
// from the master, so nothing has to be archived.
|
|
export function advanceRotation() {
|
|
const current = rotations();
|
|
if (!master()) throw new Error("no identity to rotate");
|
|
if (current >= MAX_CHAIN) throw new Error(`the rotation chain is full at ${MAX_CHAIN} links`);
|
|
update(state => { state.rotations = current + 1; });
|
|
}
|
|
|
|
// --- account and server pins ---------------------------------------------------
|
|
|
|
export function account() {
|
|
const a = load().account;
|
|
return a ? { ...a } : null;
|
|
}
|
|
|
|
export function setAccount(addr) {
|
|
update(state => {
|
|
state.account = { user: addr.user, host: addr.host, port: addr.port };
|
|
});
|
|
}
|
|
|
|
export function serverPin(host) {
|
|
const raw = (load().servers || {})[host];
|
|
return raw ? b32decode(raw) : null;
|
|
}
|
|
|
|
export function pinServer(host, keyBytes) {
|
|
update(state => {
|
|
state.servers = { ...(state.servers || {}), [host]: b32encode(keyBytes) };
|
|
});
|
|
}
|
|
|
|
export function allPins() {
|
|
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
|
|
}
|
|
|
|
// §4/first contact: when true, resolving a never-before-seen recipient's key
|
|
// from a server that isn't pinned is refused instead of merely toasted after
|
|
// the envelope is already sealed. Persisted, not in-memory — an in-memory
|
|
// toggle fails open on every restart, worth little as a security setting.
|
|
export function requirePinnedServer() {
|
|
return load().requirePinnedServer ?? false;
|
|
}
|
|
|
|
export function setRequirePinnedServer(value) {
|
|
update(state => { state.requirePinnedServer = value; });
|
|
}
|
|
|
|
// --- FETCH behavior and cursor (§6.1) -------------------------------------------
|
|
|
|
// When true, fetch does not acknowledge (delete) what it retrieves — mail
|
|
// stays on the server until explicitly deleted. Defaults to the original
|
|
// behavior: fetched mail is acknowledged immediately.
|
|
export function leaveOnServer() {
|
|
return load().leaveOnServer ?? false;
|
|
}
|
|
|
|
export function setLeaveOnServer(value) {
|
|
update(state => { state.leaveOnServer = value; });
|
|
}
|
|
|
|
// Seconds a network call waits for a response before giving up. Applied per
|
|
// read, not per operation, so a slow-but-trickling exchange is not cut off
|
|
// just because it spans several reads.
|
|
export function timeoutSeconds() {
|
|
return load().timeoutSeconds ?? 30;
|
|
}
|
|
|
|
export function setTimeoutSeconds(value) {
|
|
const n = Math.trunc(Number(value));
|
|
if (!Number.isFinite(n)) return; // non-numeric input is ignored, not an error
|
|
update(state => { state.timeoutSeconds = Math.min(600, Math.max(1, n)); });
|
|
}
|
|
|
|
// Undoes setIdentity()/restoreMaster(): onboarding's "back"/"start over",
|
|
// before anything is bound to an account. Pins and contacts are left alone —
|
|
// server trust is not identity-scoped.
|
|
export function discardIdentity() {
|
|
if (account()) throw new Error("already bound to an account; log out instead");
|
|
update(state => {
|
|
delete state.master;
|
|
delete state.rotations;
|
|
delete state.syncOk;
|
|
delete state.afterTime;
|
|
delete state.afterId;
|
|
});
|
|
}
|
|
|
|
export function cursor() {
|
|
const state = load();
|
|
const afterId = state.afterId;
|
|
return [state.afterTime ?? 0, afterId ? unhex(afterId) : new Uint8Array(ID_LEN)];
|
|
}
|
|
|
|
export function setCursor(afterTime, afterId) {
|
|
update(state => {
|
|
state.afterTime = afterTime;
|
|
state.afterId = hex(afterId);
|
|
});
|
|
}
|
|
|
|
// --- contacts ------------------------------------------------------------------
|
|
|
|
export function contact(address) {
|
|
const c = (load().contacts || {})[address];
|
|
return c ? { key: b32decode(c.key), verified: c.verified, history: c.history ?? [] } : null;
|
|
}
|
|
|
|
// A key that displaces another is kept: it is the only local record that the
|
|
// contact rotated, and §8 turns on the user being able to notice such changes.
|
|
// Re-saving the same key is not a rotation and must not add an entry.
|
|
export function saveContact(address, keyBytes, verified) {
|
|
const key = b32encode(keyBytes);
|
|
update(state => {
|
|
const contacts = { ...(state.contacts || {}) };
|
|
const previous = contacts[address];
|
|
const history = previous && previous.key !== key
|
|
? [...(previous.history ?? []), { key: previous.key, until: Date.now() }]
|
|
: previous?.history ?? [];
|
|
contacts[address] = { key, verified, seenAt: Date.now(), ...(history.length && { history }) };
|
|
state.contacts = contacts;
|
|
});
|
|
}
|
|
|
|
export function addressForKey(keyBytes) {
|
|
return Object.entries(load().contacts || {})
|
|
.find(([, c]) => c.key === b32encode(keyBytes))?.[0] ?? null;
|
|
}
|
|
|
|
export function allContacts() {
|
|
return Object.entries(load().contacts || {})
|
|
.map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] }));
|
|
}
|
|
|
|
// --- accept tokens (§5.8) --------------------------------------------------------
|
|
|
|
export function accepted(address) {
|
|
const a = (load().accepted || {})[address];
|
|
return a ? { identity: b32decode(a.identity), active: a.active } : null;
|
|
}
|
|
|
|
// Admit a contact to the main tier. The identity is frozen at acceptance — a
|
|
// re-accept after a block must not change which key the token is derived
|
|
// from (§5.8).
|
|
export function accept(address, identityBytes) {
|
|
update(state => {
|
|
const table = { ...(state.accepted || {}) };
|
|
const previous = table[address];
|
|
table[address] = {
|
|
identity: previous?.identity ?? b32encode(identityBytes),
|
|
active: true,
|
|
addedAt: previous?.addedAt ?? Date.now(),
|
|
};
|
|
state.accepted = table;
|
|
});
|
|
}
|
|
|
|
// Withdraw a contact's accept token; their mail lands in the requests tier
|
|
// from their next message on. Throws if the contact was never accepted.
|
|
export function block(address) {
|
|
if (!(load().accepted || {})[address]) throw new Error(`${address} was never accepted`);
|
|
update(state => {
|
|
const table = { ...(state.accepted || {}) };
|
|
table[address] = { ...table[address], active: false };
|
|
state.accepted = table;
|
|
});
|
|
}
|
|
|
|
export function allAccepted() {
|
|
return Object.entries(load().accepted || {})
|
|
.map(([address, a]) => ({ address, identity: b32decode(a.identity), active: a.active }));
|
|
}
|
|
|
|
// §4: the tokens to push with AUTH, and whether to push at all. A client that
|
|
// cannot vouch for its own set — one restored from the master alone — must
|
|
// not replace the server's with an incomplete one.
|
|
export function tokenSet(masterBytes) {
|
|
if (!syncOk()) return { sync: 0, tokens: [] };
|
|
const active = allAccepted().filter(a => a.active).sort((a, b) => a.address.localeCompare(b.address));
|
|
return { sync: 1, tokens: active.map(a => tokenFor(masterBytes, a.identity)) };
|
|
}
|
|
|
|
// A token received from a correspondent, filed under the address that issued
|
|
// it: an address outlives the keys behind it, so the token keeps working
|
|
// across the issuer's rotations (§5.8).
|
|
export function tokenFrom(address) {
|
|
const raw = (load().tokens || {})[address];
|
|
return raw ? b32decode(raw.token) : null;
|
|
}
|
|
|
|
export function learnToken(address, tokenBytes) {
|
|
update(state => {
|
|
const tokens = { ...(state.tokens || {}) };
|
|
tokens[address] = { token: b32encode(tokenBytes), seenAt: Date.now() };
|
|
state.tokens = tokens;
|
|
});
|
|
}
|
|
|
|
// --- read markers ---------------------------------------------------------------
|
|
|
|
export function markRead(idHex) {
|
|
update(state => {
|
|
state.read = { ...(state.read || {}), [idHex]: true };
|
|
});
|
|
}
|
|
|
|
export function isRead(idHex) {
|
|
return Boolean((load().read || {})[idHex]);
|
|
}
|
|
|
|
// --- appearance ------------------------------------------------------------
|
|
|
|
// "light" or "dark" to pin a side; null to follow the system.
|
|
export function theme() {
|
|
return load().theme ?? null;
|
|
}
|
|
|
|
export function setTheme(value) {
|
|
update(state => {
|
|
if (value) state.theme = value;
|
|
else delete state.theme;
|
|
});
|
|
}
|
|
|
|
// --- sealed mail (IndexedDB) ------------------------------------------------------
|
|
|
|
const DB_NAME = "gsmol", DB_VERSION = 1;
|
|
|
|
// Held open: reopening per read cost more than the reads themselves.
|
|
let dbPromise = null;
|
|
|
|
function openDb() {
|
|
if (dbPromise) return dbPromise;
|
|
dbPromise = new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
for (const box of ["inbox", "sent"]) {
|
|
if (!db.objectStoreNames.contains(box)) db.createObjectStore(box, { keyPath: "id" });
|
|
}
|
|
};
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
}).catch(error => { dbPromise = null; throw error; });
|
|
return dbPromise;
|
|
}
|
|
|
|
function tx(db, box, mode, fn) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = fn(db.transaction(box, mode).objectStore(box));
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// "requests" is a view over the same physical "inbox" records, filtered by
|
|
// tier (§5.8) — not a separate folder, so a message keeps one identity
|
|
// regardless of which tier it arrived in.
|
|
const physicalFolder = (folder) => folder === "requests" ? "inbox" : folder;
|
|
|
|
export async function storeMessage(box, record) {
|
|
const db = await openDb();
|
|
await tx(db, box, "readwrite", store => store.put(record));
|
|
}
|
|
|
|
// Returns null when the id already exists, so fetch can leave server state alone.
|
|
export async function storeIfNew(box, record) {
|
|
const db = await openDb();
|
|
const existing = await tx(db, box, "readonly", store => store.get(record.id));
|
|
if (existing) return null;
|
|
await tx(db, box, "readwrite", store => store.put(record));
|
|
return record;
|
|
}
|
|
|
|
export async function listMessages(folder) {
|
|
const physical = physicalFolder(folder);
|
|
const db = await openDb();
|
|
const rows = await tx(db, physical, "readonly", store => store.getAll());
|
|
const wantTier = folder === "requests" ? TIER_REQUESTS : TIER_MAIN;
|
|
const filtered = physical === "inbox" ? rows.filter(r => (r.tier ?? TIER_MAIN) === wantTier) : rows;
|
|
return filtered.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
|
|
}
|
|
|
|
export async function getMessage(folder, id) {
|
|
const db = await openDb();
|
|
return await tx(db, physicalFolder(folder), "readonly", store => store.get(id)) ?? null;
|
|
}
|
|
|
|
export async function removeMessage(folder, id) {
|
|
const db = await openDb();
|
|
await tx(db, physicalFolder(folder), "readwrite", store => store.delete(id));
|
|
}
|
|
|
|
// The unread badge must survive leaving the inbox, so it is counted from ids
|
|
// and read markers alone — no envelope is opened.
|
|
async function unreadIn(folder) {
|
|
const rows = await listMessages(folder);
|
|
const read = load().read || {};
|
|
return rows.filter(row => !read[row.id]).length;
|
|
}
|
|
|
|
export const unreadCount = () => unreadIn("inbox");
|
|
export const requestsUnreadCount = () => unreadIn("requests");
|
|
|
|
// --- export / import: mail, contacts, pins — never the master --------------------
|
|
|
|
// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per
|
|
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
|
|
// this file — no Node test exercises store.js, unlike crypto.js/proto.js.
|
|
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
|
const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
|
|
|
// A gsmol-only construction, not the wire protocol's own HKDF namespace
|
|
// ("smolmail/1 ..." in proto.js) — export/import has no counterpart in
|
|
// SPEC.md, so it gets its own label rather than borrowing the protocol's.
|
|
// Sealing to a key derived from the identity's own master means the file is
|
|
// opaque without it — no new passphrase to manage, and it can only ever be
|
|
// opened where that master is already restored, exactly the situation import
|
|
// already assumes.
|
|
const EXPORT_LABEL = utf8Bytes("gsmol/1 export");
|
|
const exportKey = (masterBytes) => hkdfSha256(masterBytes, new Uint8Array(0), EXPORT_LABEL, 32);
|
|
|
|
// Deliberately excludes the master: it already has its own reveal-and-copy
|
|
// flow in settings, meant for a password manager, not a downloadable file.
|
|
// Beyond that, everything else in here was worth hiding too — contacts and
|
|
// pins are a social graph and a list of which mail servers you use, not just
|
|
// the mail SPEC.md §5 makes irreplaceable once fetched — so the whole payload
|
|
// is sealed, not just the parts that were already ciphertext at rest.
|
|
export async function exportData() {
|
|
const m = master();
|
|
if (!m) throw new Error("no identity yet");
|
|
const [inbox, requests, sent] = await Promise.all(
|
|
[listMessages("inbox"), listMessages("requests"), listMessages("sent")]);
|
|
const payload = {
|
|
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
|
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
|
[address, { key, verified, history }])),
|
|
inbox: [...inbox, ...requests].map(row => ({
|
|
id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope),
|
|
tier: row.tier ?? TIER_MAIN, keptOnServer: row.keptOnServer ?? false,
|
|
})),
|
|
sent: sent.map(row =>
|
|
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
|
|
};
|
|
// Random per export: the key is the same every time (deterministic from the
|
|
// master), so nonce reuse has to be ruled out the way it always is under a
|
|
// fixed key — a fresh 96-bit nonce per seal, not a fixed one like seal()'s
|
|
// in proto.js gets away with (there, a fresh ephemeral key each message
|
|
// makes the derived key itself unique, so a zero nonce is safe).
|
|
const nonce = randomBytes(12);
|
|
const ciphertext = aeadEncrypt(exportKey(m), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
|
|
return {
|
|
gsmolExport: 2,
|
|
exportedAt: Date.now(),
|
|
nonce: toBase64(nonce),
|
|
ciphertext: toBase64(ciphertext),
|
|
};
|
|
}
|
|
|
|
// Never overwrites a trust binding that already differs locally — the same
|
|
// rule refreshContact()/saveReplyAddress() apply elsewhere: an existing pin
|
|
// or contact key changes only by explicit user action, never silently. A
|
|
// malformed entry (hand-edited file, corruption) is skipped, not fatal — one
|
|
// bad record cannot abort the rest of the import, matching describe()'s
|
|
// per-message fail-open elsewhere in the app.
|
|
// A plain object, as JSON.parse would produce for `{...}`; Object.entries()
|
|
// on a string iterates its characters rather than failing, which is exactly
|
|
// the kind of malformed input this rejects as one unit instead of one per char.
|
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
export async function importData(data) {
|
|
let payload;
|
|
if (data?.gsmolExport === 2) {
|
|
const m = master();
|
|
if (!m) throw new Error("no identity yet — restore it before importing");
|
|
try {
|
|
const plaintext = aeadDecrypt(
|
|
exportKey(m), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0));
|
|
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
} catch {
|
|
throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted");
|
|
}
|
|
} else if (data?.gsmolExport === 1) {
|
|
payload = data; // pre-encryption shape: the fields already sit at the top level
|
|
} else {
|
|
throw new Error("not a gsmol export file");
|
|
}
|
|
|
|
const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0,
|
|
mailAdded: 0, malformed: 0 };
|
|
|
|
update(state => {
|
|
const servers = { ...(state.servers || {}) };
|
|
if (payload.servers !== undefined && !isRecord(payload.servers)) summary.malformed++;
|
|
for (const [host, key] of Object.entries(isRecord(payload.servers) ? payload.servers : {})) {
|
|
try {
|
|
if (b32decode(key).length !== 32) throw new Error("bad length");
|
|
} catch { summary.malformed++; continue; }
|
|
if (!(host in servers)) { servers[host] = key; summary.pinsAdded++; }
|
|
else if (servers[host] !== key) summary.pinsConflicted++;
|
|
}
|
|
state.servers = servers;
|
|
|
|
const contacts = { ...(state.contacts || {}) };
|
|
if (payload.contacts !== undefined && !isRecord(payload.contacts)) summary.malformed++;
|
|
for (const [address, c] of Object.entries(isRecord(payload.contacts) ? payload.contacts : {})) {
|
|
try {
|
|
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
|
|
} catch { summary.malformed++; continue; }
|
|
if (!(address in contacts)) {
|
|
contacts[address] = { key: c.key, verified: Boolean(c.verified), seenAt: Date.now(),
|
|
...(Array.isArray(c.history) && c.history.length && { history: c.history }) };
|
|
summary.contactsAdded++;
|
|
} else if (contacts[address].key !== c.key) {
|
|
summary.contactsConflicted++;
|
|
}
|
|
}
|
|
state.contacts = contacts;
|
|
});
|
|
|
|
for (const [box, rows] of [["inbox", payload.inbox], ["sent", payload.sent]]) {
|
|
if (rows !== undefined && !Array.isArray(rows)) summary.malformed++;
|
|
for (const row of Array.isArray(rows) ? rows : []) {
|
|
try {
|
|
const record = box === "inbox"
|
|
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope),
|
|
tier: row.tier ?? TIER_MAIN, keptOnServer: Boolean(row.keptOnServer) }
|
|
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
|
|
if (await storeIfNew(box, record)) summary.mailAdded++;
|
|
} catch { summary.malformed++; }
|
|
}
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
// Logout: erases the master, pins, contacts and every cached message from
|
|
// this browser. Closes the held connection first so the delete isn't left
|
|
// "blocked" waiting for a handle that never closes on its own.
|
|
export async function clearAll() {
|
|
localStorage.removeItem(KEY);
|
|
cached = derived = null;
|
|
if (dbPromise) {
|
|
(await dbPromise).close();
|
|
dbPromise = null;
|
|
}
|
|
await new Promise((resolve, reject) => {
|
|
const request = indexedDB.deleteDatabase(DB_NAME);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
request.onblocked = () => resolve(); // still completes once the reload closes every handle
|
|
});
|
|
}
|