Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d2b24cd1 | ||
|
|
598de3ff1a | ||
|
|
ae61792aee | ||
|
|
a1f082d686 |
@@ -64,7 +64,9 @@ release/SHA256SUMS
|
||||
```
|
||||
|
||||
Nothing is published by this. Tagging, CRX packing and any upload are
|
||||
outward-facing acts and are the owner's alone.
|
||||
outward-facing acts and are the owner's alone. The full procedure that turns a
|
||||
green `main` into a tagged, packaged release — the order of steps, who performs
|
||||
each, and how to check it worked — is in [docs/RELEASE.md](docs/RELEASE.md).
|
||||
|
||||
The archives are deterministic — entries sorted, timestamps fixed, compression
|
||||
level fixed — so two builds of one commit produce byte-identical files and the
|
||||
|
||||
@@ -90,6 +90,16 @@ but the review is broader than any of them.
|
||||
approve, reject and disconnect paths against a transaction approval
|
||||
broadcasting behind them: each is declined and the dApp still receives its
|
||||
broadcast result.
|
||||
- 2026-09-21: `docs/RELEASE.md`, linked from `README.md`, states the release
|
||||
procedure as a numbered list a newcomer can follow: confirm `main` is green in
|
||||
CI, confirm the one version in the three files matches the intended tag,
|
||||
`make package` from a clean checkout, verify `SHA256SUMS`, tag `vX.Y.Z`, then
|
||||
distribute per browser. Each step names who performs it (owner-only steps
|
||||
marked) and the check that it worked. The distribution step is written as
|
||||
pending the owner's choice on
|
||||
[#386](https://git.eeqj.de/sneak/AutistMask/issues/386), with the Firefox and
|
||||
Chrome options named but none settled. Docs only
|
||||
([#387](https://git.eeqj.de/sneak/AutistMask/issues/387)).
|
||||
|
||||
- 2026-09-21: Adding a second wallet no longer accepts a different password with
|
||||
nothing saying it is a separate one
|
||||
@@ -113,6 +123,37 @@ but the review is broader than any of them.
|
||||
`Unknown token`, and a non-bundled ERC-20 is no longer carried onto the wait
|
||||
screen as `ETH`. A tracked or explorer-reported name stays subject to the
|
||||
spoof rule, so resolving a symbol is not a new way to wear a known ticker.
|
||||
- 2026-09-21: The debug/testnet banner no longer shows the internal view id to
|
||||
the user in a release build
|
||||
([#375](https://git.eeqj.de/sneak/AutistMask/issues/375)). The banner appended
|
||||
the active view's id (e.g. `[TESTNET] (approve-tx)`), which is developer
|
||||
vocabulary sitting directly above the approval screen's carefully worded
|
||||
authorization text. The suffix is now gated on the compile-time `DEBUG`
|
||||
constant rather than `isDebug()`, so it survives only in a debug build; a
|
||||
testnet or the runtime debug toggle still raises the banner but without the
|
||||
view id.
|
||||
|
||||
- 2026-09-21: The Confirm Delete button on the delete-wallet screen no longer
|
||||
stays dead after a successful delete
|
||||
([#335](https://git.eeqj.de/sneak/AutistMask/issues/335)). The password route
|
||||
disabled the button before the decrypt and never re-enabled it, so a second
|
||||
delete in the same popup session needed a reopen; the lost-password route
|
||||
re-enabled its own button in its leave hook, so the two screens behaved
|
||||
differently. Both now reset through the shared `finishDelete()`, the one path
|
||||
both routes take, so they behave the same and the button is live for the next
|
||||
delete.
|
||||
|
||||
- 2026-09-21: The EIP-6963 provider UUID is generated fresh on each page load
|
||||
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)).
|
||||
It was created once and stored, then announced verbatim to every page on every
|
||||
load and across restarts, so any site — connected or not — could read it as a
|
||||
stable cross-site, cross-session identifier for the install, contradicting the
|
||||
"no tracking" promise. inpage.js now announces a per-load
|
||||
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the
|
||||
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a
|
||||
standalone storage entry, never part of the versioned `autistmask` profile, so
|
||||
the state schema is untouched and no existing profile is affected.
|
||||
|
||||
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
||||
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
||||
wallet list was the reported case: the address shared one row with the
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Releasing AutistMask
|
||||
|
||||
This is the procedure that turns a green `main` into a tagged, packaged release.
|
||||
It gathers into one place what is otherwise spread across the `Makefile` and
|
||||
three `README.md` sections, so the person cutting a release does not have to
|
||||
reconstruct the order from them.
|
||||
|
||||
There is one version, declared in three files (`package.json`,
|
||||
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
|
||||
packages but publishes nothing. `make build` and `make package` can be run by
|
||||
anyone; tagging, signing, packing a CRX and any upload need credentials only the
|
||||
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
|
||||
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
|
||||
so the "release commit" throughout is the `main` commit the milestone PR merged.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Confirm `main` is green in CI.** The `check` workflow
|
||||
(`.gitea/workflows/check.yml`) runs `script/cibuild`, i.e. `docker build .`,
|
||||
and the `Dockerfile` runs `make check` as a build step, so a green `check`
|
||||
run is a green `make check`. Find the run for the exact release commit on the
|
||||
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
|
||||
`make check` on a clean checkout of the commit reproduces it and exits 0.
|
||||
|
||||
2. **Confirm the version matches the intended tag.** `package.json`,
|
||||
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
|
||||
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
|
||||
equal the tag you mean to create — that is this manual step. _Check:_ all
|
||||
three files read the same `X.Y.Z`, and it is the version you intend to tag
|
||||
`vX.Y.Z`.
|
||||
|
||||
3. **Build and package from a clean checkout of that commit.** From a fresh
|
||||
clone, or a working tree with no local modifications (`git status` clean),
|
||||
checked out at the release commit: run `make setup`, then `make package`.
|
||||
`make package` runs `make build` first, so the archives can only be made from
|
||||
a `dist/` verified against that build's own receipt as a release (not debug)
|
||||
build. It writes three files into `release/`:
|
||||
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
|
||||
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
|
||||
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
|
||||
(see step 6 and "Installing on Firefox" in `README.md`).
|
||||
|
||||
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
|
||||
fixed timestamps, fixed compression — so a second `make package` from another
|
||||
clean checkout of the same commit produces byte-identical files. Verify the
|
||||
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
|
||||
`release/`. To confirm reproducibility, run `make package` again on a
|
||||
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
|
||||
reports `OK` for every file, and an independent build's digests match.
|
||||
|
||||
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
|
||||
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
|
||||
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
|
||||
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
|
||||
|
||||
6. **Distribute per browser.** _(owner-only; pending the owner's choice on
|
||||
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
|
||||
each browser is not yet decided; it is the open question on that issue, and
|
||||
the concrete steps cannot be written until the owner records a choice there.
|
||||
These steps need credentials only the owner holds. The options under
|
||||
consideration are:
|
||||
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
|
||||
refuse an unsigned add-on:
|
||||
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
|
||||
with the owner's credentials; AMO returns a signed `.xpi` installable
|
||||
on every Firefox, with nothing listed publicly.
|
||||
- (b) AMO listed: as (a), plus a public AMO listing and review.
|
||||
- (c) Ship the unsigned `.xpi` and state that Firefox support means
|
||||
Developer Edition, Nightly, or an Unbranded build with
|
||||
`xpinstall.signatures.required` set to `false`.
|
||||
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
|
||||
is fixed by the `key` in `manifest/chrome.json`:
|
||||
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
|
||||
developer account; the store delivers installs and updates.
|
||||
- (b) Self-hosted CRX signed with the private key the owner holds
|
||||
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
|
||||
installable only via enterprise policy on Windows and macOS, so
|
||||
realistically Linux-only.
|
||||
- (c) "Load unpacked" from `dist/chrome/` only, as today.
|
||||
|
||||
Once the owner decides, the chosen steps — including which credentials they
|
||||
need and who holds them — are written into this section and `README.md`'s
|
||||
installation sections are updated to match, which is part of the definition
|
||||
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a
|
||||
store or AMO route, the artifact installs from the store or AMO on a clean
|
||||
browser profile; for the CRX or unpacked route, the documented load succeeds
|
||||
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
|
||||
@@ -5,8 +5,6 @@ const {
|
||||
hasBrowserNamespace,
|
||||
runtimeApi,
|
||||
sendMessage,
|
||||
storageGet,
|
||||
storageSet,
|
||||
} = require("../shared/browserApi");
|
||||
|
||||
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
||||
@@ -21,30 +19,6 @@ if (hasBrowserNamespace()) {
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
}
|
||||
|
||||
// Send the persisted EIP-6963 provider UUID to the inpage script.
|
||||
// Generated once at install time and stored in extension storage.
|
||||
(async function sendProviderUuid() {
|
||||
let uuid = null;
|
||||
try {
|
||||
const items = await storageGet("eip6963Uuid");
|
||||
uuid = items?.eip6963Uuid;
|
||||
if (!uuid) {
|
||||
uuid = crypto.randomUUID();
|
||||
await storageSet({ eip6963Uuid: uuid });
|
||||
}
|
||||
} catch {
|
||||
// Storage was unavailable or refused the write. The announcement
|
||||
// still has to go out — a provider that never announces is invisible
|
||||
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
|
||||
// page load will not outlive.
|
||||
if (!uuid) uuid = crypto.randomUUID();
|
||||
}
|
||||
window.postMessage(
|
||||
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
|
||||
location.origin,
|
||||
);
|
||||
})();
|
||||
|
||||
// Relay requests from the page to the background script
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
|
||||
+8
-11
@@ -204,7 +204,14 @@
|
||||
"</svg>",
|
||||
);
|
||||
|
||||
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
|
||||
// EIP-6963 wants a fresh UUIDv4 per page load — it identifies one
|
||||
// announcement, so a provider can be told apart from another instance of
|
||||
// itself in the same page. It is generated here and never stored:
|
||||
// announcing one persisted value to every site, on every load and across
|
||||
// restarts, turned it into a stable cross-site, cross-session tracking
|
||||
// identifier any page could read
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/398).
|
||||
const providerUuid = crypto.randomUUID();
|
||||
|
||||
function buildProviderInfo() {
|
||||
return {
|
||||
@@ -226,16 +233,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for the persisted UUID from the content script
|
||||
function onProviderUuid(event) {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
|
||||
window.removeEventListener("message", onProviderUuid);
|
||||
providerUuid = event.data.uuid;
|
||||
announceProvider();
|
||||
}
|
||||
window.addEventListener("message", onProviderUuid);
|
||||
|
||||
window.addEventListener("eip6963:requestProvider", announceProvider);
|
||||
announceProvider();
|
||||
|
||||
|
||||
@@ -51,16 +51,12 @@ function clear() {
|
||||
// The lost-password screen holds no secret — a wallet name is not one —
|
||||
// but it is wiped on leave for the neighbouring reason: a typed
|
||||
// confirmation left standing in a hidden view is one click away from
|
||||
// destroying a wallet the user has since navigated off. The button is
|
||||
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
|
||||
// destroying a wallet the user has since navigated off.
|
||||
function clearLostPassword() {
|
||||
lostPasswordIndex = null;
|
||||
$("delete-wallet-lost-name-input").value = "";
|
||||
$("delete-wallet-lost-flash").textContent = "";
|
||||
$("delete-wallet-lost-flash").style.visibility = "hidden";
|
||||
const btn = $("btn-delete-wallet-lost-confirm");
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
}
|
||||
|
||||
function show(walletIdx) {
|
||||
@@ -98,6 +94,17 @@ function showLostPassword() {
|
||||
// cleanup and the accountsChanged broadcast cannot drift apart between
|
||||
// them.
|
||||
async function finishDelete(walletIdx) {
|
||||
// Each route's confirm button was disabled by its own click handler
|
||||
// before the delete ran. Re-enable both here, on the one path they
|
||||
// share, so the two routes reset the same way and a second delete in
|
||||
// the same popup session finds a live button instead of a dead one.
|
||||
const passwordBtn = $("btn-delete-wallet-confirm");
|
||||
passwordBtn.disabled = false;
|
||||
passwordBtn.classList.remove("text-muted");
|
||||
const lostPasswordBtn = $("btn-delete-wallet-lost-confirm");
|
||||
lostPasswordBtn.disabled = false;
|
||||
lostPasswordBtn.classList.remove("text-muted");
|
||||
|
||||
const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
|
||||
|
||||
deleteWalletIndex = null;
|
||||
@@ -187,8 +194,8 @@ function init(_ctx) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
|
||||
// finishDelete() navigates, and the leave hook re-enables the
|
||||
// button and wipes the typed name on the way out.
|
||||
// finishDelete() re-enables the button; navigating away then runs
|
||||
// the leave hook that wipes the typed name.
|
||||
await finishDelete(lostPasswordIndex);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// escapeHtml lives in src/shared/html.js, where the escape and the
|
||||
// reasoning behind it are; it is re-exported below so views keep importing
|
||||
// it from here.
|
||||
const { DEBUG } = require("../../shared/constants");
|
||||
const { escapeHtml } = require("../../shared/html");
|
||||
const { isDebug } = require("../../shared/log");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
@@ -119,7 +120,11 @@ function updateDebugBanner(viewName) {
|
||||
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
const suffix = viewName ? " (" + viewName + ")" : "";
|
||||
// The view id is internal vocabulary; it helps while developing but
|
||||
// means nothing to a user. Only a debug build appends it, gated on the
|
||||
// compile-time DEBUG constant so a release build never shows it — not
|
||||
// isDebug(), which is also true for a testnet or the runtime toggle.
|
||||
const suffix = DEBUG && viewName ? " (" + viewName + ")" : "";
|
||||
if (debug && net.isTestnet) {
|
||||
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
|
||||
} else if (net.isTestnet) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Tests for the debug/testnet banner (issue #375).
|
||||
//
|
||||
// On a testnet the banner is raised even in a release build, but it must not
|
||||
// append the active view's internal id: the user should see "[TESTNET]", never
|
||||
// "[TESTNET] (approve-tx)". The suffix is gated on the compile-time DEBUG
|
||||
// constant, which is false in a plain test load, so this drives exactly the
|
||||
// text a shipped build renders. Revert the gate to the old unconditional
|
||||
// suffix and this fails.
|
||||
//
|
||||
// The banner is created on demand by updateDebugBanner(); the document stub
|
||||
// records what it prepends so the assertion can read the resulting text.
|
||||
|
||||
function makeBanner() {
|
||||
return {
|
||||
id: "",
|
||||
textContent: "",
|
||||
style: { cssText: "" },
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
let banner = null;
|
||||
return {
|
||||
getElementById(id) {
|
||||
return id === "debug-banner" ? banner : null;
|
||||
},
|
||||
createElement: () => makeBanner(),
|
||||
body: {
|
||||
prepend(node) {
|
||||
banner = node;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
jest.resetModules();
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
globalThis.document = makeDocument();
|
||||
const helpers = require("../src/popup/views/helpers");
|
||||
const { state } = require("../src/shared/state");
|
||||
return { helpers, state };
|
||||
}
|
||||
|
||||
describe("the release banner on a testnet", () => {
|
||||
test("carries no internal view id", () => {
|
||||
const { helpers, state } = load();
|
||||
state.networkId = "sepolia";
|
||||
|
||||
helpers.updateDebugBanner("approve-tx");
|
||||
|
||||
expect(
|
||||
globalThis.document.getElementById("debug-banner").textContent,
|
||||
).toBe("[TESTNET]");
|
||||
});
|
||||
});
|
||||
@@ -172,6 +172,15 @@ async function openLostPassword(deleteWallet, walletIdx) {
|
||||
await click("btn-delete-wallet-lost-password");
|
||||
}
|
||||
|
||||
// Delete a wallet through the password route: open its confirm screen,
|
||||
// enter the password, and confirm. The vault is mocked, so the password
|
||||
// text itself is irrelevant — decryptWithPassword decides pass or fail.
|
||||
async function deleteWithPassword(deleteWallet, walletIdx) {
|
||||
deleteWallet.show(walletIdx);
|
||||
node("delete-wallet-password").value = "any password";
|
||||
await click("btn-delete-wallet-confirm");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ tests
|
||||
|
||||
// The stub is what every persistence assertion below rests on, so its one
|
||||
@@ -456,15 +465,21 @@ describe("what the screen leaves behind", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Left mid-delete, the screen has to come back usable.
|
||||
test("the confirm button is re-enabled on the way out", async () => {
|
||||
const { helpers, deleteWallet } = load();
|
||||
// Both routes now re-enable through finishDelete(), not their leave
|
||||
// hooks, so the button comes back live once a delete completes.
|
||||
test("the confirm button is re-enabled after a delete", async () => {
|
||||
const { deleteWallet } = load();
|
||||
await openLostPassword(deleteWallet, 1);
|
||||
|
||||
node("btn-delete-wallet-lost-confirm").disabled = true;
|
||||
helpers.showView("settings");
|
||||
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
||||
await click("btn-delete-wallet-lost-confirm");
|
||||
|
||||
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
|
||||
expect(
|
||||
node("btn-delete-wallet-lost-confirm").classList.contains(
|
||||
"text-muted",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// A wallet name is not a secret, so the screen is excluded for the
|
||||
@@ -475,3 +490,48 @@ describe("what the screen leaves behind", () => {
|
||||
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The password route is the pre-existing bug this file's fix addresses:
|
||||
// its Confirm Delete button was disabled before the decrypt and never
|
||||
// re-enabled on success, so a second delete in the same popup session
|
||||
// found a dead button. Now both routes re-enable through finishDelete().
|
||||
//
|
||||
// Against head these tests fail: with the re-enable absent, the button
|
||||
// stays disabled after the first delete, so the disabled assertions read
|
||||
// true where they expect false.
|
||||
describe("the password route's confirm button", () => {
|
||||
test("is re-enabled after a successful delete", async () => {
|
||||
const { deleteWallet, vault } = load();
|
||||
vault.decryptWithPassword.mockResolvedValue();
|
||||
|
||||
await deleteWithPassword(deleteWallet, 1);
|
||||
|
||||
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
|
||||
expect(
|
||||
node("btn-delete-wallet-confirm").classList.contains("text-muted"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// The reported symptom: delete one wallet, then open Delete Wallet for
|
||||
// a second one without reopening the popup. The button must be live on
|
||||
// that second visit, and the second delete must actually persist.
|
||||
test("a second delete works in the same popup session", async () => {
|
||||
const { deleteWallet, vault, storage } = load();
|
||||
vault.decryptWithPassword.mockResolvedValue();
|
||||
|
||||
await deleteWithPassword(deleteWallet, 1);
|
||||
|
||||
// Wallet 2 is gone; the list is now [Wallet 1, Wallet 3]. Opening
|
||||
// the confirm screen for the wallet now at index 1 (Wallet 3) must
|
||||
// find its button live, not the dead one the first delete left.
|
||||
deleteWallet.show(1);
|
||||
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
|
||||
|
||||
node("delete-wallet-password").value = "any password";
|
||||
await click("btn-delete-wallet-confirm");
|
||||
|
||||
expect((await persistedWallets(storage)).map((w) => w.name)).toEqual([
|
||||
"Wallet 1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
||||
STEP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// EIP-6963, asked of the provider itself. The announcement carries the
|
||||
// uuid src/content/index.js reads out of extension storage — call site 1
|
||||
// in the issue — and it has to name this extension and hand back the very
|
||||
// object on window.ethereum.
|
||||
// EIP-6963, asked of the provider itself. The announcement carries a
|
||||
// UUIDv4 inpage.js generates fresh for this page load (nothing persists
|
||||
// it — see issue #398) and has to name this extension and hand back the
|
||||
// very object on window.ethereum.
|
||||
const announced = await d.executeAsync(
|
||||
`const done = arguments[arguments.length - 1];
|
||||
const onAnnounce = (e) => {
|
||||
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
||||
);
|
||||
assert(
|
||||
typeof announced.uuid === "string" && announced.uuid.length === 36,
|
||||
"the announcement carries no stored provider uuid: " +
|
||||
"the announcement carries no provider uuid: " +
|
||||
JSON.stringify(announced.uuid),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// The EIP-6963 provider UUID inpage.js announces (src/content/inpage.js).
|
||||
//
|
||||
// The bug this pins down (issue #398): the UUID used to be generated once,
|
||||
// persisted in extension storage, and announced verbatim to every page on
|
||||
// every load and across browser restarts, so any site — connected or not —
|
||||
// could read a stable cross-site, cross-session identifier for the install.
|
||||
// EIP-6963 wants a fresh UUIDv4 per announcement instead. The fix generates
|
||||
// it per page load and stores nothing.
|
||||
//
|
||||
// inpage.js is a bare IIFE injected into the page's JS context, not a module;
|
||||
// see tests/inpageErrors.test.js for why it is evaluated against a stub window
|
||||
// rather than imported. Here the stub captures the CustomEvent that carries
|
||||
// the announcement, so the UUID this file reads is the one a real dApp's
|
||||
// eip6963:announceProvider listener would see.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { webcrypto } = require("crypto");
|
||||
|
||||
const SOURCE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "content", "inpage.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const loadInto = new Function(
|
||||
"window",
|
||||
"self",
|
||||
"crypto",
|
||||
"Event",
|
||||
"CustomEvent",
|
||||
SOURCE,
|
||||
);
|
||||
|
||||
class StubEvent {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
class StubCustomEvent extends StubEvent {
|
||||
constructor(type, init) {
|
||||
super(type);
|
||||
this.detail = init && init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate inpage.js once against a fresh stub window and return every UUID it
|
||||
// announced. A `requestProvider` event is dispatched too, so a re-announcement
|
||||
// within one load is observed as well as the announcement at load.
|
||||
function announcedUuids() {
|
||||
const listeners = {};
|
||||
const uuids = [];
|
||||
|
||||
const win = {
|
||||
addEventListener(type, fn) {
|
||||
(listeners[type] || (listeners[type] = [])).push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
const fns = listeners[type];
|
||||
if (!fns) return;
|
||||
const i = fns.indexOf(fn);
|
||||
if (i !== -1) fns.splice(i, 1);
|
||||
},
|
||||
postMessage() {},
|
||||
dispatchEvent(event) {
|
||||
if (event.type === "eip6963:announceProvider") {
|
||||
uuids.push(event.detail.info.uuid);
|
||||
}
|
||||
for (const fn of (listeners[event.type] || []).slice()) fn(event);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
win.window = win;
|
||||
|
||||
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
|
||||
win.dispatchEvent(new StubEvent("eip6963:requestProvider"));
|
||||
return uuids;
|
||||
}
|
||||
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("the EIP-6963 provider UUID is fresh per page load", () => {
|
||||
test("a load announces a UUIDv4, unprompted, with nothing delivered", () => {
|
||||
const uuids = announcedUuids();
|
||||
expect(uuids.length).toBeGreaterThan(0);
|
||||
expect(uuids[0]).toMatch(UUID_V4);
|
||||
});
|
||||
|
||||
test("every announcement within one load carries the same UUID", () => {
|
||||
const uuids = announcedUuids();
|
||||
expect(uuids.length).toBeGreaterThan(1);
|
||||
expect(new Set(uuids).size).toBe(1);
|
||||
});
|
||||
|
||||
test("two page loads announce different UUIDs", () => {
|
||||
const first = announcedUuids()[0];
|
||||
const second = announcedUuids()[0];
|
||||
expect(first).toMatch(UUID_V4);
|
||||
expect(second).toMatch(UUID_V4);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user