diff --git a/Makefile b/Makefile index 3f03164..9d57ac5 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,32 @@ BUILDDIR := build/ -NAME := ipvfoo -VERSION := $(shell cat src/manifest.json | \ - sed -n 's/^ *"version": *"\([0-9.]\+\)".*/\1/p' | \ - head -n1) +NAME := ipvfoo +MANIFEST := src/manifest.json +MANIFEST_F := src/manifest/firefox-manifest.json +MANIFEST_C := src/manifest/chrome-manifest.json +VERSION_F := $(shell cat ${MANIFEST_F} | \ + sed -n 's/^ *"version": *"\([0-9.]\+\)".*/\1/p' | \ + head -n1) +VERSION_C := $(shell cat ${MANIFEST_C} | \ + sed -n 's/^ *"version": *"\([0-9.]\+\)".*/\1/p' | \ + head -n1) all: prepare firefox chrome prepare: + @diff ${MANIFEST} ${MANIFEST_F} >/dev/null || \ + diff ${MANIFEST} ${MANIFEST_C} >/dev/null || \ + (echo "${MANIFEST} is not a copy of ${MANIFEST_F} or ${MANIFEST_C}; aborting."; exit 1) mkdir -p build firefox: prepare - rm -f ${BUILDDIR}${NAME}-${VERSION}.xpi - cd src && zip -9r ../${BUILDDIR}${NAME}-${VERSION}.xpi * + rm -f ${BUILDDIR}${NAME}-${VERSION_F}.xpi + cp -f ${MANIFEST_F} ${MANIFEST} + zip -9j ${BUILDDIR}${NAME}-${VERSION_F}.xpi -j src/* chrome: prepare - rm -f ${BUILDDIR}${NAME}-${VERSION}.zip - zip -9r ${BUILDDIR}${NAME}-${VERSION}.zip src + rm -f ${BUILDDIR}${NAME}-${VERSION_C}.zip + cp -f ${MANIFEST_C} ${MANIFEST} + zip -9j ${BUILDDIR}${NAME}-${VERSION_C}.zip -j src/* clean: rm -rf ${BUILDDIR} diff --git a/README.md b/README.md index d47c87b..ebe750f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,49 @@ -IPvFoo is a Chrome extension that adds an icon to your location bar, indicating whether the current page was fetched using IPv4 or IPv6. When you click the icon, a pop-up appears, listing the IP address for each domain that served the page elements. +**IPvFoo** is a Chrome/Firefox extension that adds an icon to indicate whether the current page was fetched using IPv4 or IPv6. When you click the icon, a pop-up appears, listing the IP address for each domain that served the page elements. -Everything is captured privately using the webRequest API (new in Chrome 17), without creating any additional network traffic. +Everything is captured privately using the webRequest API, without creating any additional network traffic. -#### Install it from the Chrome Web Store: -https://chrome.google.com/webstore/detail/ecanpcehffngcegjmadlcijfolapggal +## Screenshots +![Screenshot](/misc/screenshot_webstore_1_640x400.png?raw=true) -#### Screenshot: -![Screenshot](/misc/screenshot_webstore_640x400.png?raw=true) +![Screenshot](/misc/screenshot_options.png?raw=true) -#### Firefox Support: -IPvFoo now [runs on Firefox](https://addons.mozilla.org/en-US/firefox/addon/ipvfoo-pmarks/), but there are [a few bugs](https://github.com/pmarks-net/ipvfoo/issues/32) to work out. +## Add to Chrome +https://chrome.google.com/webstore/detail/ipvfoo/ecanpcehffngcegjmadlcijfolapggal + + + + + +## Add to Firefox +https://addons.mozilla.org/addon/ipvfoo/ + + + + + +## Add to Edge +https://microsoftedge.microsoft.com/addons/detail/ipvfoo/dphnkggpaicipkljebciobedeiaiofod +*(You can also run the Chrome version on Edge, as they are identical.)* + +## Safari? + +IPvFoo cannot be [ported to Safari](https://github.com/pmarks-net/ipvfoo/issues/39) because the `webRequest` API does not report IP addresses. In theory, a Safari extension could do its own DNS lookups over HTTPS, but such behavior is beyond the scope of IPvFoo. + +## Running IPvFoo unpacked from git + +IPvFoo shares a common codebase for Chrome and Firefox, but `manifest.json` is browser specific. + +Firefox shows this error when running the Chrome version: + +> There was an error during the temporary add-on installation. +> background.service_worker is currently disabled. Add background.scripts. + +Chrome shows this error when running the Firefox version: + +> 'background.scripts' requires manifest version of 2 or lower. +> 'page_action' requires manifest version of 2 or lower. + +The `use_*_manifest.sh.bat` scripts in the [manifest](src/manifest/) directory may be used to switch between versions. + +


+Donate: https://liberapay.com/pmarks diff --git a/misc/privacy_policy.txt b/misc/privacy_policy.txt index 534a9fe..fcb7734 100644 --- a/misc/privacy_policy.txt +++ b/misc/privacy_policy.txt @@ -1,8 +1,15 @@ Privacy Policy -IPvFoo monitors all of your web traffic, in order to present a table of -connection information. This information is stored in local RAM, and is -not transmitted over the network. +IPvFoo has full access to your web traffic, as this is necessary to present +the table of connection information. All information is kept in RAM on your +computer, and never transmitted over the network. -If you use the "Look up on bgp.he.net" feature, the selected domain name -or IP address will be sent to Hurricane Electric via URL request. +Technically there is one exception: if you use the "Look up on bgp.he.net" +feature, your browser will navigate to a URL managed by Hurricane Electric, +containing the selected domain name or IP address. + + +My other Chrome extensions do not use personal information at all: +- Incognito Proxy +- Endless Whisper +-
 Line Wrapper
diff --git a/misc/screenshot_edge_toolbar_1280x800.png b/misc/screenshot_edge_toolbar_1280x800.png
new file mode 100644
index 0000000..f2de3dd
Binary files /dev/null and b/misc/screenshot_edge_toolbar_1280x800.png differ
diff --git a/misc/screenshot-firefox1.png b/misc/screenshot_firefox.png
similarity index 100%
rename from misc/screenshot-firefox1.png
rename to misc/screenshot_firefox.png
diff --git a/misc/screenshot_firefox_android.png b/misc/screenshot_firefox_android.png
new file mode 100644
index 0000000..013af3a
Binary files /dev/null and b/misc/screenshot_firefox_android.png differ
diff --git a/misc/screenshot_firefox_permission.png b/misc/screenshot_firefox_permission.png
new file mode 100644
index 0000000..d842736
Binary files /dev/null and b/misc/screenshot_firefox_permission.png differ
diff --git a/misc/screenshot_options.png b/misc/screenshot_options.png
new file mode 100644
index 0000000..a748a5d
Binary files /dev/null and b/misc/screenshot_options.png differ
diff --git a/misc/screenshot_webstore_1280x800.png b/misc/screenshot_webstore_1280x800.png
deleted file mode 100644
index 252b0ec..0000000
Binary files a/misc/screenshot_webstore_1280x800.png and /dev/null differ
diff --git a/misc/screenshot_webstore_1_1280x800.png b/misc/screenshot_webstore_1_1280x800.png
new file mode 100755
index 0000000..c6e2737
Binary files /dev/null and b/misc/screenshot_webstore_1_1280x800.png differ
diff --git a/misc/screenshot_webstore_1_640x400.png b/misc/screenshot_webstore_1_640x400.png
new file mode 100644
index 0000000..a8fad0f
Binary files /dev/null and b/misc/screenshot_webstore_1_640x400.png differ
diff --git a/misc/screenshot_webstore_2_1280x800.png b/misc/screenshot_webstore_2_1280x800.png
new file mode 100755
index 0000000..a5e8810
Binary files /dev/null and b/misc/screenshot_webstore_2_1280x800.png differ
diff --git a/misc/screenshot_webstore_640x400.png b/misc/screenshot_webstore_640x400.png
deleted file mode 100644
index e434239..0000000
Binary files a/misc/screenshot_webstore_640x400.png and /dev/null differ
diff --git a/src/1x1_808080.png b/src/1x1_808080.png
new file mode 100644
index 0000000..c918a96
Binary files /dev/null and b/src/1x1_808080.png differ
diff --git a/src/background.js b/src/background.js
index 2ceca40..607f194 100644
--- a/src/background.js
+++ b/src/background.js
@@ -35,133 +35,50 @@ Popup updates begin sooner, in wR.onBeforeRequest(main_frame), because the
 user can demand a popup before any IP addresses are available.
 */
 
-// Returns an Object with no default properties.
-function newMap() {
-  return Object.create(null);
-}
-
-// requestId -> {tabInfo, domain}
-const requestMap = newMap(); 
-
-// tabId -> TabInfo
-const tabMap = newMap();
-
-// Images from spritesXX.png: [x, y, w, h]
-const spriteBig = {
-  "4": {16: [1, 1, 9, 14],
-        32: [1, 1, 21, 28]},
-  "6": {16: [11, 1, 9, 14],
-        32: [23, 1, 21, 28]},
-  "?": {16: [21, 1, 9, 14],
-        32: [45, 1, 21, 28]},
-};
-const spriteSmall = {
-  "4": {16: [31, 1, 6, 6],
-        32: [67, 1, 10, 10]},
-  "6": {16: [31, 8, 6, 6],
-        32: [67, 12, 10, 10]},
-};
+"use strict";
 
-// Destination coordinates: [x, y]
-const targetBig = {
-  16: [0, 1],
-  32: [0, 2],
-};
-const targetSmall1 = {
-  16: [10, 1],
-  32: [22, 2],
-};
-const targetSmall2 = {
-  16: [10, 8],
-  32: [22, 14],
-};
+if (chrome.runtime.getManifest().background.service_worker) {
+  // This only runs on Chrome.
+  // Firefox uses manifest.json/background/scripts instead.
+  importScripts("iputil.js", "common.js");
+}
 
 // Possible states for an instance of TabInfo.
 // We begin at BIRTH, and only ever move forward, not backward.
-const TAB_BIRTH = 0;    // Waiting for TabTracker onConnect
-const TAB_ALIVE = 1;    // Waiting for TabTracker onDisconnect
-const TAB_DELETED = 2;  // Dead.
+const TAB_BIRTH = 0;    // Waiting for makeAlive() or remove()
+const TAB_ALIVE = 1;    // Waiting for remove()
+const TAB_DEAD = 2;
 
 // RequestFilter for webRequest events.
 const FILTER_ALL_URLS = { urls: [""] };
 
-// Whitelist IP address and domain name characters.
-const IP_CHARS = /^[0-9A-Fa-f:.]+$/;
+// Distinguish IP address and domain name characters.
+// Note that IP6_CHARS must not match "beef.de"
+const IP4_CHARS = /^[0-9.]+$/;
+const IP6_CHARS = /^[0-9A-Fa-f]*:[0-9A-Fa-f:.]*$/;
 const DNS_CHARS = /^[0-9A-Za-z._-]+$/;
 
-// Load spriteXX.png of a particular size.
-// Executing this inline ensures that the images load before
-// firing the onload handler.
-function loadSpriteImg(size) {
-  const s = document.createElement("img");
-  s.src = "sprites" + size + ".png";
-  return s;
-}
-const spriteImg = {
-  16: loadSpriteImg(16),
-  32: loadSpriteImg(32),
-};
+const SECONDS = 1000;  // to milliseconds
 
-// Get a  element of the given size.  We could get away with just one,
-// but seeing them side-by-side helps with multi-DPI debugging.
-const canvasElements = newMap();
-function getCanvasContext(size) {
-  let c = canvasElements[size];
-  if (!c) {
-    c = canvasElements[size] = document.createElement("canvas");
-    c.width = c.height = size;
-    document.body.appendChild(c);
-  }
-  return c.getContext("2d");
-}
-
-// pattern is 0..3 characters, each '4', '6', or '?'.
-// size is 16 or 32.
-// color is "lightfg" or "darkfg".
-function buildIcon(pattern, size, color) {
-  const ctx = getCanvasContext(size);
-  ctx.clearRect(0, 0, size, size);
-  if (pattern.length >= 1) {
-    drawSprite(ctx, size, targetBig, spriteBig[pattern.charAt(0)]);
-  }
-  if (pattern.length >= 2) {
-    drawSprite(ctx, size, targetSmall1, spriteSmall[pattern.charAt(1)]);
-  }
-  if (pattern.length >= 3) {
-    drawSprite(ctx, size, targetSmall2, spriteSmall[pattern.charAt(2)]);
-  }
-  const imageData = ctx.getImageData(0, 0, size, size);
-  if (color == "lightfg") {
-    // Apply the light foreground color.
-    const px = imageData.data;
-    const floor = 128;
-    for (var i = 0; i < px.length; i += 4) {
-      px[i+0] += floor;
-      px[i+1] += floor;
-      px[i+2] += floor;
-    }
-  }
-  return imageData;
-}
+const NAME_VERSION = (() => {
+  const m = chrome.runtime.getManifest();
+  return `${m.name} v${m.version}`;
+})();
 
-function drawSprite(ctx, size, targets, sources) {
-  const source = sources[size];
-  const target = targets[size];
-  // (image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
-  ctx.drawImage(spriteImg[size],
-                source[0], source[1], source[2], source[3],
-                target[0], target[1], source[2], source[3]);
+let debug = false;
+function debugLog() {
+  if (debug) {
+    console.log(new Date().toISOString(), ...arguments);
+  }
 }
 
-// In theory, we should be using a full-blown subnet parser/matcher here,
-// but let's keep it simple and stick with text for now.
-function addrToVersion(addr) {
-  if (addr) {
-    if (/^64:ff9b::/.test(addr)) return "4";  // RFC6052
-    if (addr.indexOf(".") >= 0) return "4";
-    if (addr.indexOf(":") >= 0) return "6";
-  }
-  return "?";
+// Log errors from async listeners, because otherwise Firefox hides them
+// in the global console.
+function wrap(f) {
+  const tracer = new Error("wrap() stack trace");
+  return (...args) => f(...args).catch((err) => {
+    console.error("Error in async listener:", err, tracer);
+  });
 }
 
 function parseUrl(url) {
@@ -169,15 +86,14 @@ function parseUrl(url) {
   let ssl = false;
   let ws = false;
 
-  const a = document.createElement("a");
-  a.href = url;
-  if (a.protocol == "file:") {
+  const u = new URL(url);
+  if (u.protocol == "file:") {
     domain = "file://";
-  } else if (a.protocol == "chrome:") {
+  } else if (u.protocol == "chrome:") {
     domain = "chrome://";
   } else {
-    domain = a.hostname || "";
-    switch (a.protocol) {
+    domain = u.hostname || "";
+    switch (u.protocol) {
       case "https:":
         ssl = true;
         break;
@@ -189,341 +105,744 @@ function parseUrl(url) {
         break;
     }
   }
-  return { domain: domain, ssl: ssl, ws: ws, origin: a.origin };
+  return { domain: domain, ssl: ssl, ws: ws, origin: u.origin };
 }
 
-// -- TabInfo --
+/**
+ * Gets the domain of a tab from source of truth 
+ * that is, the domain of the URL that is currently displayed in the URL bar.
+ * @param {number} tabId - The tab ID to get the URL of.
+ * @returns {Promise} - The domain of the tab.
+ */
+async function getTrueTabDomain(tabId) {
+  try {
+    const tabInfo = await chrome.tabs.get(tabId);
+    const domain = parseUrl(tabInfo.url).domain;
+    debugLog("getTrueTabDomain success", domain);
+    return domain;
+  } catch (error) {
+    debugLog("getTrueTabDomain error", error);
+    return null;
+  }
+}
 
-const TabInfo = function(tabId) {
-  this.tabId = tabId;
-  this.state = TAB_BIRTH;     // See the TAB_* constants above.
-  this.mainDomain = "";       // Bare domain from the main_frame request.
-  this.mainOrigin = "";       // Origin from the main_frame request.
-  this.dataExists = false;    // True if we have data to publish.
-  this.committed = false;     // True if onCommitted has fired.
-  this.domains = newMap();    // Updated whenever we get some IPs.
-  this.spillCount = 0;        // How many requests didn't fit in domains.
-  this.lastPattern = "";      // To avoid redundant icon redraws.
-  this.lastTooltip = "";      // To avoid redundant tooltip updates.
-  this.accessDenied = false;  // webRequest events aren't permitted.
-  this.color = "regularColorScheme";  // ... or incognitoColorScheme.
-
-  // First, clean up the previous TabInfo, if any.
-  tabTracker.disconnect(tabId);
-  if (tabMap[tabId]) throw "Duplicate entry in tabMap";
-  tabMap[tabId] = this;
-
-  // Start polling for the tab's existence.
-  const that = this;
-  tabTracker.connect(tabId, function() {
-    // onConnect: Yay, the tab exists; maybe give it an icon.
-    if (that.state != TAB_BIRTH) throw "Unexpected onConnect!";
-    that.state = TAB_ALIVE;
-    that.updateIcon();
-  }, function() {
-    // onDisconnect: Tell in-flight requests/timeouts to ignore this instance.
-    if (that.state == TAB_DELETED) throw "Redundant onDisconnect!";
-    that.state = TAB_DELETED;
-    delete tabMap[that.tabId];
-  });
-};
+/**
+ * Checks if the true tab domain matches the source of truth
+ * @param {Object} details - The details object from the webRequest event
+ * @returns {Promise} - True if the true tab domain matches the source of truth, false otherwise
+ */
+async function isMainTabUrl(details) {
+  if (details.type != "main_frame" && details.type != "outermost_frame") {
+    return false;
+  }
+  try {
+    const trueTabDomain = await getTrueTabDomain(details.tabId);
+    return trueTabDomain === parseUrl(details.url).domain;
+  } catch (error) {
+    debugLog("isMainTabUrl error", error);
+    return false;
+  }
+}
 
-TabInfo.prototype.setInitialDomain = function(domain, origin) {
-  this.mainDomain = domain;
-  this.mainOrigin = origin;
+function updateNAT64(domain, addr) {
+  if (!(IPV4_ONLY_DOMAINS.has(domain) && addr)) {
+    return;
+  }
+  const packed = parseIP(addr);
+  if (packed.length != 128/4) {
+    return;  // not an IPv6 address
+  }
+  // Heuristic: Don't consider this a NAT64 prefix if the embedded
+  // IPv4 address falls under 0.x.x.x/8.  This filters out cases where all
+  // traffic is proxied to the same address, assuming that most proxies
+  // have a low-numbered suffix like ::1.
+  if (packed.substr(96/4, 2) == '00') {
+    return;
+  }
+  // If this is a new prefix, the watchOptions callback will handle it.
+  addPackedNAT64(packed.slice(0, 96/4));
+}
 
-  // If anyone's watching, show some preliminary state.
-  popups.pushAll(this.tabId);
-};
+function reformatForNAT64(addr, doLookup=true) {
+  let packed128 = "";
+  try {
+    packed128 = parseIP(addr);
+  } catch {
+    return addr;  // no change
+  }
+  if (packed128.length != 128/4) {
+    return addr;  // no change
+  }
+  const isNAT64 = doLookup && options[NAT64_KEY].has(packed128.slice(0, 96/4));
+  return formatIPv6(packed128, /*with_dots=*/isNAT64);
+}
+
+// Magic object that calls action and/or pageAction. We want an icon in the
+// address bar when possible (e.g. desktop Firefox) but have a fallback option
+// when browsers forget to implement pageAction (e.g. Firefox 142 for Android).
+const actions = new Proxy({}, {
+  get(target, prop) {
+    const apis = [chrome.action, chrome.pageAction].filter(Boolean);
+    return (...args) => {
+      for (const api of apis) {
+        if (typeof api[prop] === 'function') {
+          api[prop](...args);
+        } else if (prop != 'show') {  // action.show() shouldn't exist.
+          throw new Error(`actions.${prop} is not a function`);
+        }
+      }
+    };
+  }
+});
 
-TabInfo.prototype.setCommitted = function(domain, origin) {
-  if (this.state == TAB_DELETED) throw "Impossible";
+class SaveableEntry {
+  #prefix;
+  #id;
+  #dirty = false;
+  #remove = false;
+  #savedJSON = null;
+
+  constructor(prefix, id) {
+    if (!prefix) throw "missing prefix";
+    if (!id) throw "missing id";
+    this.#prefix = prefix;
+    this.#id = id;
+  }
 
-  const oldState = [this.accessDenied, this.mainDomain];
+  id() { return this.#id; }
 
-  if (origin != this.mainOrigin) {
-    // We never saw a main_frame webRequest for this page, so it must've
-    // been blocked by some policy.  Wipe all the state to avoid reporting
-    // misleading information.  Known cases where this can occur:
-    // - chrome:// URLs
-    // - file:// URLs (when "allow" is unchecked)
-    // - Pages in the Chrome Web Store
-    this.domains = newMap();
-    this.spillCount = 0;
-    this.accessDenied = true;
+  load(j) {
+    this.#savedJSON = j;
+    for (const [k, v] of Object.entries(JSON.parse(j))) {
+      if (this.hasOwnProperty(k)) {
+        this[k] = v;
+      } else {
+        console.error("skipping unknown key", k);
+      }
+    }
+    return this;
+  }
+
+  // Limit to 1 in-flight chrome.storage operation per key.
+  // No need to await.
+  async save() {
+    if (this.#dirty) {
+      return;  // Already saving.
+    }
+    this.#dirty = true;
+    await null;  // Let the caller finish first.
+    while (this.#dirty) {
+      this.#dirty = false;
+      const key = `${this.#prefix}${this.#id}`
+      if (this.#remove) {
+        await chrome.storage.session.remove(key);
+        return;
+      }
+      const j = JSON.stringify(this);
+      if (this.#savedJSON == j) {
+        return;
+      }
+      //console.log("saving", key, j);
+      await chrome.storage.session.set({[key]: j});
+      this.#savedJSON = j;
+    }
   }
 
-  this.mainDomain = domain;
-  this.dataExists = true;
-  this.committed = true;
+  // No need to await.
+  async remove() {
+    this.#remove = true;
+    await this.save();
+  }
+}
 
-  // This is usually redundant, but lastPattern takes care of it.
-  this.updateIcon();
+class SaveableMap {
+  #factory;
+  #prefix;
 
-  // If the table contents changed, then redraw it.
-  const newState = [this.accessDenied, this.mainDomain];
-  if (oldState.toString() != newState.toString()) {
-    popups.pushAll(this.tabId);
+  constructor(factory, prefix) {
+    this.#factory = factory;
+    this.#prefix = prefix;
   }
-};
 
-// If the pageAction is supposed to be visible now, then draw it again.
-TabInfo.prototype.refreshPageAction = function() {
-  this.lastPattern = "";
-  this.lastTooltip = "";
-  this.updateIcon();
-};
+  validateId(id) {
+    if (this.#prefix == "ip/") {
+      // Don't restrict ipCache domain name keys.
+      return id;
+    } else {
+      const idNumeric = parseInt(id, 10);
+      if (idNumeric) {
+        return idNumeric;
+      }
+    }
+    throw `malformed id: ${id}`;
+  }
+
+  load(key, savedJSON) {
+    if (!key.startsWith(this.#prefix)) {
+      return false;
+    }
+    const suffix = key.slice(this.#prefix.length);
+    let id;
+    try {
+      id = this.validateId(suffix);
+    } catch(err) {
+      console.error(err);
+      return false;
+    }
+    this[id] = new this.#factory(this.#prefix, id).load(savedJSON);
+    return true;
+  }
 
-TabInfo.prototype.addDomain = function(domain, addr, flags) {
-  if (this.state == TAB_DELETED) throw "Impossible";
+  lookupOrNew(id) {
+    id = this.validateId(id);
+    let o = this[id];
+    if (!o) {
+      o = this[id] = new this.#factory(this.#prefix, id);
+    }
+    return o;
+  }
+
+  remove(id) {
+    id = this.validateId(id);
+    const o = this[id];
+    if (o) {
+      delete this[id];
+      o.remove();
+    }
+    return o;
+  }
+}
+
+// -- TabInfo --
+
+class TabInfo extends SaveableEntry {
+  born = Date.now();     // For TabTracker timeout.
+  mainRequestId = null;  // Request that constructed this tab, if any.
+  mainDomain = "";       // Bare domain from the main_frame request.
+  mainOrigin = "";       // Origin from the main_frame request.
+  committed = false;     // True if onCommitted has fired.
+  domains = newMap();    // Updated whenever we get some IPs.
+  spillCount = 0;        // How many requests didn't fit in domains.
+  lastPattern = "";      // To avoid redundant icon redraws.
+  lastTooltip = "";      // To avoid redundant tooltip updates.
+  color = REGULAR_COLOR  // or INCOGNITO_COLOR
+
+  // Private, to avoid writing to storage.
+  #state = TAB_BIRTH;
+
+  constructor(prefix, tabId) {
+    super(prefix, tabId);
+
+    if (!spriteImg.ready) throw "must await spriteImgReady!";
+    if (!options.ready) throw "must await optionsReady!";
+
+    if (tabMap[tabId]) throw "Duplicate entry in tabMap";
+    if (tabTracker.exists(tabId)) {
+      this.makeAlive();
+    }
+  }
+
+  afterLoad() {
+    for (const [domain, json] of Object.entries(this.domains)) {
+      this.domains[domain] = DomainInfo.fromJSON(this, domain, json);
+    }
+    updateOriginMap(this.id(), null, this.mainOrigin);
+  }
 
-  const oldDomainInfo = this.domains[domain];
-  let connCount = null;
-  flags |= FLAG_CONNECTED;
+  tooYoungToDie() {
+    // Spare new tabs from garbage collection for a minute or so.
+    return (this.#state == TAB_BIRTH &&
+            this.born >= Date.now() - 60*SECONDS);
+  }
 
-  if (!oldDomainInfo) {
-    // Limit the number of domains per page, to avoid wasting RAM.
-    if (Object.keys(this.domains).length >= 256) {
-      popups.pushSpillCount(this.tabId, ++this.spillCount);
+  makeAlive() {
+    if (this.#state != TAB_BIRTH) {
       return;
     }
-    // Run this after the last connection goes away.
-    const that = this;
-    connCount = new ConnectionCounter(function() {
-      if (that.state == TAB_DELETED) {
+    this.#state = TAB_ALIVE;
+    this.updateIcon();
+  }
+
+  remove() {
+    super.remove();  // no await
+    this.#state = TAB_DEAD;
+    this.domains = newMap();
+    updateOriginMap(this.id(), this.mainOrigin, null);
+  }
+
+  setInitialDomain(requestId, domain, origin) {
+    if (this.mainRequestId == null) {
+      this.mainRequestId = requestId;
+    } else if (this.mainRequestId != requestId) {
+      console.error("mainRequestId changed!");
+    }
+    this.mainDomain = domain;
+    updateOriginMap(this.id(), this.mainOrigin, origin);
+    this.mainOrigin = origin;
+
+    // If anyone's watching, show some preliminary state.
+    this.pushAll();
+    this.save();
+  }
+
+  setCommitted(domain, origin) {
+    let changed = false;
+
+    if (this.mainDomain != domain) {
+      this.mainDomain = domain;
+      changed = true;
+    }
+    this.committed = true;
+
+    // This is usually redundant, but lastPattern takes care of it.
+    this.updateIcon();
+
+    // If the table contents changed, then redraw it.
+    if (changed) {
+      this.pushAll();
+    }
+
+    this.save();
+  }
+
+  // If the pageAction is supposed to be visible now, then draw it again.
+  refreshPageAction() {
+    this.lastTooltip = "";
+    this.lastPattern = "";
+    this.updateIcon();
+    this.save();
+  }
+
+  addDomain(domain, addr, flags) {
+    let d = this.domains[domain];
+    if (!d) {
+      // Limit the number of domains per page, to avoid wasting RAM.
+      if (Object.keys(this.domains).length >= 256) {
+        popups.pushSpillCount(this.id(), ++this.spillCount);
         return;
       }
-      const d = that.domains[domain];
-      if (d) {
-        d.flags &= ~FLAG_CONNECTED;
-        popups.pushOne(that.tabId, domain, d.addr, d.flags);
+      d = this.domains[domain] =
+          new DomainInfo(this, domain, addr || "(lost)", flags);
+      d.countUp();
+    } else {
+      const oldAddr = d.addr;
+      const oldFlags = d.flags;
+      // Don't allow a cached IP to overwrite an actually-connected IP.
+      if (addr && ((flags & FLAG_UNCACHED) || !(oldFlags & FLAG_UNCACHED))) {
+        d.addr = addr;
+      }
+      // Merge in the previous flags.
+      d.flags |= flags;
+      d.countUp();
+      // Don't update if nothing has changed.
+      if (d.addr == oldAddr && d.flags == oldFlags) {
+        return;
       }
-    });
-    connCount.up();
-  } else {
-    connCount = oldDomainInfo.connCount;
-    connCount.up();
-    // Don't allow a cached IP to overwrite an actually-connected IP.
-    if (!(flags & FLAG_UNCACHED) && (oldDomainInfo.flags & FLAG_UNCACHED)) {
-      addr = oldDomainInfo.addr;
-    }
-    // Merge in the previous flags.
-    flags |= oldDomainInfo.flags;
-    // Don't update if nothing has changed.
-    if (oldDomainInfo.addr == addr && oldDomainInfo.flags == flags) {
-      return;
     }
+
+    this.updateIcon();
+    this.pushOne(domain);
+    this.save();
   }
 
-  this.domains[domain] = {
-    addr: addr,
-    flags: flags,
-    connCount: connCount,
-  };
-  this.dataExists = true;
+  updateIcon() {
+    if (!(this.#state == TAB_ALIVE)) {
+      return;
+    }
+    let pattern = "?";
+    let has4 = false;
+    let has6 = false;
+    let tooltip = "";
+    for (const [domain, d] of Object.entries(this.domains)) {
+      if (domain == this.mainDomain) {
+        pattern = d.addrVersion();
+        if (IS_MOBILE) {
+          tooltip = d.addr;  // Limited tooltip space on Android.
+        } else {
+          tooltip = `${d.addr}\n${NAME_VERSION}`;
+        }
+      } else {
+        switch (d.addrVersion()) {
+          case "4": has4 = true; break;
+          case "6": has6 = true; break;
+        }
+      }
+    }
+    if (has4) pattern += "4";
+    if (has6) pattern += "6";
+
+    // Don't waste time rewriting the same tooltip.
+    if (this.lastTooltip != tooltip) {
+      actions.setTitle({
+        "tabId": this.id(),
+        "title": tooltip,
+      });
+      this.lastTooltip = tooltip;
+      this.save();
+    }
 
-  this.updateIcon();
-  popups.pushOne(this.tabId, domain, addr, flags);
-};
+    // Don't waste time redrawing the same icon.
+    if (this.lastPattern != pattern) {
+      const color = options[this.color];
+      actions.setIcon({
+        "tabId": this.id(),
+        "imageData": {
+          "16": buildIcon(pattern, 16, color),
+          "32": buildIcon(pattern, 32, color),
+        },
+      });
+      // Send icon to the popup window.
+      popups.pushPattern(this.id(), pattern, this.color);
+      actions.setPopup({
+        "tabId": this.id(),
+        "popup": `popup.html#${this.id()}`,
+      });
+      actions.show(this.id());
+      this.lastPattern = pattern;
+      this.save();
+    }
+  }
 
-TabInfo.prototype.disconnectDomain = function(domain) {
-  const d = this.domains[domain];
-  if (d) {
-    d.connCount.down();
+  pushAll() {
+    popups.pushAll(this.id(), this.getTuples(), this.lastPattern, this.color, this.spillCount);
   }
-};
 
-TabInfo.prototype.updateIcon = function() {
-  if (!(this.state == TAB_ALIVE && this.dataExists)) {
-    return;
+  pushOne(domain) {
+    popups.pushOne(this.id(), this.getTuple(domain));
   }
-  const domains = Object.keys(this.domains);
-  let pattern = "?";
-  let has4 = false;
-  let has6 = false;
-  let tooltip = "";
-  for (const domain of domains) {
-    const addr = this.domains[domain].addr;
-    const version = addrToVersion(addr);
-    if (domain == this.mainDomain) {
-      pattern = version;
-      tooltip = addr + " - IPvFoo";
-    } else {
-      switch (version) {
-        case "4": has4 = true; break;
-        case "6": has6 = true; break;
+
+  // Build some [domain, addr, version, flags] tuples, for a popup.
+  getTuples() {
+    const mainDomain = this.mainDomain || "(no domain)";
+    const domains = Object.keys(this.domains).sort();
+    const mainTuple = [mainDomain, "(no address)", "?", FLAG_UNCACHED | FLAG_NOTWORKER];
+    const tuples = [mainTuple];
+    for (const domain of domains) {
+      const d = this.domains[domain];
+      if (domain == mainTuple[0]) {
+        mainTuple[1] = d.addr;
+        mainTuple[2] = d.addrVersion();
+        mainTuple[3] = d.flags;
+      } else {
+        tuples.push([domain, d.addr, d.addrVersion(), d.flags]);
       }
     }
+    return tuples;
   }
-  if (has4) pattern += "4";
-  if (has6) pattern += "6";
 
-  // Don't waste time rewriting the same tooltip.
-  if (this.lastTooltip != tooltip) {
-    chrome.pageAction.setTitle({
-      "tabId": this.tabId,
-      "title": tooltip,
-    });
-    this.lastTooltip = tooltip;
+  // Build [domain, addr, version, flags] tuple, for a popup.
+  getTuple(domain) {
+    const d = this.domains[domain];
+    if (!d) {
+      // Perhaps this.domains was cleared during the request's lifetime.
+      return null;
+    }
+    return [domain, d.addr, d.addrVersion(), d.flags];
   }
+}
 
-  // Don't waste time redrawing the same icon.
-  if (this.lastPattern == pattern) {
-    return;
+class DomainInfo {
+  tabInfo;
+  domain;
+  addr;
+  flags;
+
+  count = 0;  // count of active requests
+  inhibitZero = false;
+
+  constructor(tabInfo, domain, addr, flags) {
+    this.tabInfo = tabInfo;
+    this.domain = domain;
+    this.addr = addr;
+    this.flags = flags;
   }
-  this.lastPattern = pattern;
-
-  const color = options[this.color];
-  chrome.pageAction.setIcon({
-    "tabId": this.tabId,
-    "imageData": {
-      // Note: It might be possible to avoid redundant operations by reading
-      //       window.devicePixelRatio
-      "16": buildIcon(pattern, 16, color),
-      "32": buildIcon(pattern, 32, color),
-    },
-  });
-  chrome.pageAction.setPopup({
-    "tabId": this.tabId,
-    "popup": "popup.html#" + this.tabId,
-  });
-  chrome.pageAction.show(this.tabId);
-};
 
-// Build some [domain, addr, version, flags] tuples, for a popup.
-TabInfo.prototype.getTuples = function() {
-  if (this.state == TAB_DELETED) throw "Impossible";
-
-  const mainDomain = this.mainDomain || "---";
-  if (this.accessDenied) {
-    return [[mainDomain, "(access denied)", "?", FLAG_UNCACHED]];
-  }
-  const domains = Object.keys(this.domains).sort();
-  const mainTuple = [mainDomain, "(no address)", "?", 0];
-  const tuples = [mainTuple];
-  for (const domain of domains) {
-    const addr = this.domains[domain].addr;
-    const version = addrToVersion(addr);
-    const flags = this.domains[domain].flags;
-    if (domain == mainTuple[0]) {
-      mainTuple[1] = addr;
-      mainTuple[2] = version;
-      mainTuple[3] = flags;
-    } else {
-      tuples.push([domain, addr, version, flags]);
+  // count and FLAG_CONNECTED will be computed from requestMap.
+  toJSON() {
+    return [this.addr, this.flags & ~FLAG_CONNECTED];
+  }
+
+  static fromJSON(tabInfo, domain, json) {
+    const [addr, flags] = json;
+    return new DomainInfo(tabInfo, domain, addr, flags);
+  }
+
+  addrVersion() {
+    if (this.addr) {
+      // NAT64 addresses use the prefix::a.b.c.d format.
+      if (this.addr.indexOf(".") >= 0) return "4";
+      if (this.addr.indexOf(":") >= 0) return "6";
     }
+    return "?";
   }
-  return tuples;
-};
 
-// -- ConnectionCounter --
-// This class counts the number of active connections to a particular domain.
-// Whenever the count reaches zero, run the onZero function.  This will remove
-// the highlight from the popup.  The timer enforces a minimum hold time.
+  async countUp() {
+    this.flags |= FLAG_CONNECTED;
+    if (++this.count == 1 && !this.inhibitZero) {
+      // Keep the address highlighted for at least 500ms.
+      this.inhibitZero = true;
+      await sleep(500);
+      this.inhibitZero = false;
+      this.#checkZero();
+    }
+  }
 
-const ConnectionCounter = function(onZero) {
-  this.onZero = onZero;
-  this.count = 0;
-  this.timer = null;
-};
+  countDown() {
+    if (!(this.count > 0)) throw "Count went negative!";
+    --this.count;
+    this.#checkZero();
+  }
 
-ConnectionCounter.prototype.up = function() {
-  const that = this;
-  if (++that.count == 1 && !that.timer) {
-    that.timer = setTimeout(function() {
-      that.timer = null;
-      if (that.count == 0) {
-        that.onZero();
-      }
-    }, 500);
+  #checkZero() {
+    if (this.count == 0 && !this.inhibitZero) {
+      this.flags &= ~FLAG_CONNECTED;
+      this.tabInfo.pushOne(this.domain);
+    }
   }
-};
+}
 
-ConnectionCounter.prototype.down = function() {
-  if (!(this.count > 0)) throw "Count went negative!";
-  if (--this.count == 0 && !this.timer) {
-    this.onZero();
+class RequestInfo extends SaveableEntry {
+  // Typically this contains one {tabId: tabBorn} entry,
+  // but for Service Worker requests there may be multiple tabs.
+  tabIdToBorn = newMap();
+  domain = null;
+
+  afterLoad() {
+    for (const [tabId, tabBorn] of Object.entries(this.tabIdToBorn)) {
+      const tabInfo = tabMap[tabId];
+      if (tabInfo?.born != tabBorn) {
+        delete this.tabIdToBorn[tabId];
+        continue;
+      }
+      if (!this.domain) {
+        continue;  // still waiting for onResponseStarted
+      }
+      tabInfo.addDomain(this.domain, null, 0);
+    }
+    if (Object.keys(this.tabIdToBorn).length == 0) {
+      requestMap.remove(this.id());
+      console.log("garbage-collected RequestInfo", this.id());
+      return;
+    }
   }
-};
+}
 
-// -- Popups --
+class IPCacheEntry extends SaveableEntry {
+  time = 0;
+  addr = "";
+}
 
-// This class keeps track of the visible popup windows,
-// and streams changes to them as they occur.
-const Popups = function() {
-  this.map = newMap();      // tabId -> popup window
-  this.hasTimeout = false;  // Is the GC scheduled?
-};
+// tabId -> TabInfo
+const tabMap = new SaveableMap(TabInfo, "tab/")
 
-// Attach a new popup window, and start sending it updates.
-Popups.prototype.attachWindow = function(win) {
-  this.map[win.tabId] = win;
-  this.pushAll(win.tabId);
-  this.garbageCollect();
-};
+// requestId -> RequestInfo
+const requestMap = new SaveableMap(RequestInfo, "req/");
 
-// Periodically make sure this.map is a subset of the visible popups.
-Popups.prototype.garbageCollect = function() {
-  if (this.hasTimeout) {
+// Firefox-only domain->ip cache, to help work around
+// https://bugzilla.mozilla.org/show_bug.cgi?id=1395020
+const IP_CACHE_LIMIT = 1024;
+const ipCache = (typeof browser == "undefined") ? null : new SaveableMap(IPCacheEntry, "ip/");
+let ipCacheSize = 0;
+
+function ipCacheGrew() {
+  ++ipCacheSize;
+  //console.log("ipCache", ipCacheSize, Object.keys(ipCache).length);
+  if (ipCacheSize <= IP_CACHE_LIMIT) {
     return;
   }
-  if (Object.keys(this.map).length == 0) {
-    return;
+  // Garbage collect half the entries.
+  const flat = Object.values(ipCache);
+  flat.sort((a, b) => a.time - b.time);
+  ipCacheSize = flat.length;  // redundant
+  for (const cachedAddr of flat) {
+    ipCache.remove(cachedAddr.id());
+    if (--ipCacheSize <= IP_CACHE_LIMIT/2) {
+      break;
+    }
   }
-  this.hasTimeout = true;
-  const that = this;
-  setTimeout(function() {
-    // Find all the tabs with active popups.
-    const popupTabs = newMap();
-    const popups = chrome.extension.getViews({type:"popup"});
-    for (const popup of popups) {
-      popupTabs[popup.tabId] = true;
-    }
-
-    // Drop references to the inactive popups.
-    const storedTabs = Object.keys(that.map);
-    for (const tabId of storedTabs) {
-      if (!popupTabs[tabId]) {
-        delete that.map[tabId];
+}
+
+// mainOrigin -> Set of tabIds, for tabless service workers.
+const originMap = newMap();
+
+function updateOriginMap(tabId, oldOrigin, newOrigin) {
+  if (oldOrigin && oldOrigin != newOrigin) {
+    const tabs = originMap[oldOrigin];
+    if (tabs) {
+      tabs.delete(tabId);
+      if (!tabs.size) {
+        delete originMap[oldOrigin];
       }
     }
+  }
+  if (newOrigin) {
+    let tabs = originMap[newOrigin];
+    if (!tabs) {
+      tabs = originMap[newOrigin] = new Set();
+    }
+    tabs.add(tabId);
+  }
+}
 
-    // Maybe schedule another run.
-    that.hasTimeout = false;
-    that.garbageCollect();
-  }, 5000);
-};
+function lookupOriginMap(origin) {
+  // returns a Set of tabId values.
+  return originMap[origin] || new Set();
+}
 
-Popups.prototype.pushAll = function(tabId) {
-  const win = this.map[tabId];
-  const tabInfo = tabMap[tabId];
-  if (win && tabInfo) {
-    win.pushAll(tabInfo.getTuples(), tabInfo.spillCount);
-  }
-};
+// Dark mode detection. This can eventually be replaced by
+// https://github.com/w3c/webextensions/issues/229
+if (typeof window !== 'undefined' && window.matchMedia) {
+  // Firefox can detect dark mode from the background page.
+  (async () => {
+    await optionsReady;
+    const query = window.matchMedia('(prefers-color-scheme: dark)');
+    setColorIsDarkMode(REGULAR_COLOR, query.matches);
+    query.addEventListener("change", (event) => {
+      setColorIsDarkMode(REGULAR_COLOR, event.matches);
+    });
+  })();
+} else {
+  // Chrome needs an offscreen document to detect dark mode.
+  chrome.runtime.onMessage.addListener((message) => {
+    console.log("onMessage", message);
+    if (message.hasOwnProperty("darkModeOffscreen")) {
+      setColorIsDarkMode(REGULAR_COLOR, message.darkModeOffscreen);
+    }
+  });
 
-Popups.prototype.pushOne = function(tabId, domain, addr, flags) {
-  const win = this.map[tabId];
-  if (win) {
-    win.pushOne([domain, addr, addrToVersion(addr), flags]);
-  }
-};
+  (async () => {
+    await optionsReady;
+    try {
+      await chrome.offscreen.createDocument({
+        url: "detectdarkmode.html",
+        reasons: ['MATCH_MEDIA'],
+        justification: 'detect light/dark mode for icon colors',
+      });
+    } catch {
+      console.log("detectdarkmode failed!");
+    }
+    // The offscreen document can't provide darkMode updates, so kill it now.
+    // We will still get updates from the popup windows when visible.
+    try {
+      await chrome.offscreen.closeDocument();
+    } catch {
+      // ignore
+    }
+  })();
+}
 
-Popups.prototype.pushSpillCount = function(tabId, count) {
-  const win = this.map[tabId];
-  if (win) {
-    win.pushSpillCount(count);
+// Must "await storageReady;" before reading maps.
+// You can force initStorage() from the console for debugging purposes.
+const initStorage = async () => {
+  await spriteImgReady;
+  await optionsReady;
+
+  // These are be no-ops unless initStorage() is called manually.
+  clearMap(tabMap);
+  clearMap(requestMap);
+  if (ipCache) clearMap(ipCache);
+
+  const items = await chrome.storage.session.get();
+  const unparseable = [];
+  for (const [k, v] of Object.entries(items)) {
+    if (!(tabMap.load(k, v) || requestMap.load(k, v) || ipCache?.load(k, v))) {
+      unparseable.push(k);
+    }
+  }
+  if (unparseable.length) {
+    console.error("skipped unparseable keys:", unparseable);
+  }
+  // Reconsitute the DomainInfo objects and connection counts.
+  for (const tabInfo of Object.values(tabMap)) {
+    tabInfo.afterLoad();
+  }
+  for (const requestInfo of Object.values(requestMap)) {
+    requestInfo.afterLoad();
+  }
+  if (ipCache) {
+    ipCacheSize = Object.keys(ipCache).length;
   }
 };
+const storageReady = initStorage();
+
+// -- Popups --
+
+// This class keeps track of the visible popup windows,
+// and streams changes to them as they occur.
+class Popups {
+  ports = newMap();  // tabId -> Port
+
+  // Attach a new popup window, and start sending it updates.
+  attachPort(port) {
+    const tabId = port.name;
+    this.ports[tabId] = port;
+    tabMap[tabId]?.pushAll();
+  };
+
+  detachPort(port) {
+    const tabId = port.name;
+    delete this.ports[tabId];
+  };
 
-Popups.prototype.shake = function(tabId) {
-  const win = this.map[tabId];
-  if (win) {
-    win.shake();
+  pushAll(tabId, tuples, pattern, color, spillCount) {
+    this.ports[tabId]?.postMessage({
+      cmd: "pushAll",
+      tuples: tuples,
+      pattern: pattern,
+      color: color,
+      spillCount: spillCount,
+    });
+  };
+
+  pushOne(tabId, tuple) {
+    if (!tuple) {
+      return;
+    }
+    this.ports[tabId]?.postMessage({
+      cmd: "pushOne",
+      tuple: tuple,
+    });
+  };
+
+  pushPattern(tabId, pattern, color) {
+    this.ports[tabId]?.postMessage({
+      cmd: "pushPattern",
+      pattern: pattern,
+      color: color,
+    });
+  };
+
+  pushSpillCount(tabId, count) {
+    this.ports[tabId]?.postMessage({
+      cmd: "pushSpillCount",
+      spillCount: count,
+    });
+  };
+
+  shake(tabId) {
+    this.ports[tabId]?.postMessage({
+      cmd: "shake",
+    });
   }
 }
 
-window.popups = new Popups();
+const popups = new Popups();
+
+chrome.runtime.onConnect.addListener(wrap(async (port) => {
+  await storageReady;
+  popups.attachPort(port);
+  port.onDisconnect.addListener(() => {
+    popups.detachPort(port);
+  });
+}));
+
+// Refresh icons after chrome.runtime.reload()
+chrome.runtime.onInstalled.addListener(wrap(async () => {
+  await storageReady;
+  for (const tabInfo of Object.values(tabMap)) {
+    tabInfo.refreshPageAction();
+  }
+}));
 
 // -- TabTracker --
 
@@ -539,236 +858,279 @@ window.popups = new Popups();
 //
 // Once a tab has become visible, then hopefully we can rely on the onRemoved
 // event to fire sometime in the future, when the user closes it.
-const TabTracker = function() {
-  this.tabSet = newMap();               // Set of all known tabIds
-  this.timers = newMap();               // tabId -> clearTimeout key
-  this.connectCallbacks = newMap();     // tabId -> onConnect callback
-  this.disconnectCallbacks = newMap();  // tabId -> onDisconnect callback
-
-  const that = this;
-  chrome.tabs.onCreated.addListener(function(tab) {
-    that.addTab_(tab.id, "onCreated");
-  });
-  chrome.tabs.onRemoved.addListener(function(tabId) {
-    that.removeTab_(tabId, "onRemoved");
-  });
-  chrome.tabs.onReplaced.addListener(function(addId, removeId) {
-    that.removeTab_(removeId, "onReplaced");
-    that.addTab_(addId, "onReplaced");
-  });
-  this.pollAllTabs_();
-};
-
-// Begin watching this tabId.  If the tab exists, then onConnect fires
-// immediately (or within 30 seconds), otherwise onDisconnect fires to indicate
-// failure.  After a successful connection, onDisconnect fires when the tab
-// finally does go away.
-TabTracker.prototype.connect = function(tabId, onConnect, onDisconnect) {
-  if (tabId in this.timers ||
-      tabId in this.connectCallbacks ||
-      tabId in this.disconnectCallbacks) {
-    throw "Duplicate connection: " + tabId;
-  }
-  this.connectCallbacks[tabId] = onConnect;
-  this.disconnectCallbacks[tabId] = onDisconnect;
-  if (tabId in this.tabSet) {
-    // Connect immediately.
-    this.finishConnect_(tabId);
-  } else {
-    // Disconnect if the tab doesn't appear within 30 seconds.
-    const that = this;
-    this.timers[tabId] = setTimeout(function() {
-      that.disconnect(tabId);
-    }, 30000);
+class TabTracker {
+  tabSet = newMap();  // Set of all known tabIds
+
+  constructor() {
+    chrome.tabs.onCreated.addListener(wrap(async (tab) => {
+      await storageReady;
+      this.#addTab(tab.id, "onCreated");
+    }));
+    chrome.tabs.onRemoved.addListener(wrap(async (tabId) => {
+      await storageReady;
+      this.#removeTab(tabId, "onRemoved");
+    }));
+    chrome.tabs.onReplaced.addListener(wrap(async (addId, removeId) => {
+      await storageReady;
+      this.#removeTab(removeId, "onReplaced");
+      this.#addTab(addId, "onReplaced");
+    }));
+    this.#pollAllTabs();
   }
-};
 
-// If a watcher is bound to this tabId, then disconnect it.
-TabTracker.prototype.disconnect = function(tabId) {
-  const timer = this.timers[tabId];
-  const onDisconnect = this.disconnectCallbacks[tabId];
-  delete this.timers[tabId];
-  delete this.connectCallbacks[tabId];
-  delete this.disconnectCallbacks[tabId];
-  if (timer) {
-    clearTimeout(timer);
-  }
-  if (onDisconnect) {
-    onDisconnect();
+  exists(tabId) {
+    return !!this.tabSet[tabId];
   }
-};
 
-// If a watcher is waiting for this tabId, then connect it.
-TabTracker.prototype.finishConnect_ = function(tabId) {
-  const timer = this.timers[tabId];
-  const onConnect = this.connectCallbacks[tabId];
-  delete this.timers[tabId];
-  delete this.connectCallbacks[tabId];
-  if (timer) {
-    clearTimeout(timer);
-  }
-  if (onConnect) {
-    if (!this.disconnectCallbacks[tabId]) {
-      throw "onConnect requires an onDisconnect!";
+  // Every 5 minutes (or after a service_worker restart),
+  // poke any tabs that have become out of sync.
+  async #pollAllTabs() {
+    await storageReady;  // load 'born' timestamps first.
+    while (true) {
+      const result = await chrome.tabs.query({});
+      this.tabSet = newMap();
+      for (const tab of result) {
+        this.#addTab(tab.id, "pollAlltabs")
+      }
+      for (const tabId of Object.keys(tabMap)) {
+        if (!this.tabSet[tabId]) {
+          this.#removeTab(tabId, "pollAllTabs");
+        }
+      }
+      await sleep(300*SECONDS);
     }
-    onConnect();
   }
-};
 
-// Given two set-like objects, return "a - b".
-function subtractSets(a, b) {
-  const out = [];
-  for (x in a) if (!(x in b)) {
-    out.push(x);
+  #addTab(tabId, logText) {
+    debugLog("addTab", tabId, logText);
+    this.tabSet[tabId] = true;
+    tabMap[tabId]?.makeAlive();
   }
-  return out;
-}
-
-// Get the set of all known tabs, and synchronize our state by calling
-// add/remove on the differences.  After the startup run, this should ideally
-// become a no-op, provided that the events are all firing as expected.
-// But just in case, repeat every few minutes to check for garbage.
-TabTracker.prototype.pollAllTabs_ = function() {
-  const that = this;
-  chrome.tabs.query({}, function(result) {
-    const newTabSet = newMap();
-    for (const r of result) {
-      newTabSet[r.id] = true;
-    }
-    const toAdd = subtractSets(newTabSet, that.tabSet);
-    const toRemove = subtractSets(that.tabSet, newTabSet);
-    for (const id of toAdd) {
-      that.addTab_(id, "pollAllTabs_");
-    }
-    for (const id of toRemove) {
-      console.log("Removing garbage tab: " + id);
-      that.removeTab_(id, "pollAllTabs_");
-    }
-    // Check again in 5 minutes.
-    setTimeout(function() { that.pollAllTabs_() }, 5 * 60000);
-  });
-};
-
-// Record that this tabId now exists.
-TabTracker.prototype.addTab_ = function(tabId, logText) {
-  this.tabSet[tabId] = true;
-  this.finishConnect_(tabId);
-};
 
-// Record that this tabId no longer exists.
-TabTracker.prototype.removeTab_ = function(tabId, logText) {
-  delete this.tabSet[tabId];
-  this.disconnect(tabId);
-};
+  #removeTab(tabId, logText) {
+    debugLog("removeTab", tabId, logText);
+    delete this.tabSet[tabId];
+    if (tabMap[tabId]?.tooYoungToDie()) {
+      return;
+    }
+    tabMap.remove(tabId);
+  }
+}
 
 const tabTracker = new TabTracker();
 
 // -- webNavigation --
 
-chrome.webNavigation.onCommitted.addListener(function(details) {
+// Typically, onBeforeNavigate fires between the main_frame
+// onBeforeRequest and onResponseStarted events, and we don't have to do
+// anything here.
+//
+// However, when the site is using a service worker, the main_frame request
+// never happens, so we need to initialize the tab here instead.
+//
+// Conveniently, this also ensures that the previous page data is cleared
+// when navigating to a file://, chrome://, or Chrome Web Store URL.
+chrome.webNavigation.onBeforeNavigate.addListener(wrap(async (details) => {
+  if (!(details.frameId == 0 && details.tabId > 0)) {
+    return;
+  }
+  await storageReady;
+  let tabInfo = tabMap[details.tabId];
+  const requestInfo = requestMap[tabInfo?.mainRequestId];
+  if (requestInfo && requestInfo.domain == null) {
+    return;  // Typical no-op case.
+  }
+  debugLog(`tabId=${details.tabId} is a service worker or special URL`);
+  const parsed = parseUrl(details.url);
+  tabMap.remove(details.tabId);
+  tabInfo = tabMap.lookupOrNew(details.tabId);
+  tabInfo.setInitialDomain(-1, parsed.domain, parsed.origin);
+}));
+
+chrome.webNavigation.onCommitted.addListener(wrap(async (details) => {
+  debugLog("wN.oC", details?.tabId, details?.url, details);
+  await storageReady;
   if (details.frameId != 0) {
     return;
   }
   const parsed = parseUrl(details.url);
-  const tabInfo = tabMap[details.tabId] || new TabInfo(details.tabId);
+  const tabInfo = tabMap.lookupOrNew(details.tabId);
   tabInfo.setCommitted(parsed.domain, parsed.origin);
-});
+}));
 
 // -- tabs --
 
 // Whenever anything tab-related happens, try to refresh the pageAction.  This
 // is hacky and inefficient, but the back-stabbing browser leaves me no choice.
 // This seems to fix http://crbug.com/124970 and some problems on Google+.
-chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) {
+chrome.tabs.onUpdated.addListener(wrap(async (tabId, changeInfo, tab) => {
+  debugLog("tabs.oU", tabId);
+  await storageReady;
   const tabInfo = tabMap[tabId];
   if (tabInfo) {
-    tabInfo.color = tab.incognito ?
-        "incognitoColorScheme" : "regularColorScheme";
+    tabInfo.color = tab.incognito ? INCOGNITO_COLOR : REGULAR_COLOR;
     tabInfo.refreshPageAction();
   }
-});
+}));
 
 // -- webRequest --
 
-chrome.webRequest.onBeforeRequest.addListener(function (details) {
-  if (!details.tabId || details.tabId == -1) {
-    // This request isn't related to a tab.
+chrome.webRequest.onBeforeRequest.addListener(wrap(async (details) => {
+  //debugLog("wR.oBR", details?.tabId, details?.url, details);
+  await storageReady;
+  const tabId = details.tabId;
+  const tabInfos = [];
+  if (tabId > 0) {
+    if (await isMainTabUrl(details)) {
+      const parsed = parseUrl(details.url);
+      tabMap.remove(tabId);
+      const tabInfo = tabMap.lookupOrNew(tabId);
+      tabInfo.setInitialDomain(details.requestId, parsed.domain, parsed.origin);
+      tabInfos.push(tabInfo);
+    } else {
+      const tabInfo = tabMap[tabId];
+      if (tabInfo) {
+        tabInfos.push(tabInfo);
+      }
+    }
+  } else if (tabId == -1 && (details.initiator || details.documentUrl)) {
+    // Chrome uses initiator, Firefox uses documentUrl.
+    const initiator = details.initiator || parseUrl(details.documentUrl).origin;
+    // Request is from a tabless Service Worker.
+    // Find all tabs matching the initiator's origin.
+    for (const tabId of lookupOriginMap(initiator)) {
+      const tabInfo = tabMap[tabId];
+      if (tabInfo) {
+        tabInfos.push(tabInfo);
+      }
+    }
+  }
+  if (!tabInfos.length) {
     return;
   }
-  if (details.type == "main_frame") {
-    const parsed = parseUrl(details.url);
-    new TabInfo(details.tabId).setInitialDomain(
-        parsed.domain, parsed.origin);
+  const requestInfo = requestMap.lookupOrNew(details.requestId);
+  if (requestInfo.tabIdToBorn.size || requestInfo.domain) {
+    // Can this actually happen?
+    console.error("duplicate request; connection count leak");
   }
-  const tabInfo = tabMap[details.tabId];
-  if (!tabInfo) {
-    return;
+  for (const tabInfo of tabInfos) {
+    requestInfo.tabIdToBorn[tabInfo.id()] = tabInfo.born;
   }
-  requestMap[details.requestId] = {
-    tabInfo: tabInfo,
-    domain: null,
-  };
-}, FILTER_ALL_URLS);
+  requestInfo.domain = null;
+  requestInfo.save();
+}), FILTER_ALL_URLS);
 
-// In the event of an HSTS redirect, the mainOrigin may change
+// In the event of a redirect, the mainOrigin may change
 // (from http: to https:) between the onBeforeRequest and onCommitted events,
-// triggering an "access denied" error.  We use onSendHeaders to patch this,
-// because it fires in between, providing the correct origin.
+// triggering an "access denied" error.  Patch this from onBeforeRedirect.
 //
-// However, we must treat this event as optional, because file:// and
-// ServiceWorker URLs are known to skip over it.
-chrome.webRequest.onSendHeaders.addListener(function (details) {
-  if (details.type != "main_frame") {
+// As of 2022, this can be tested by visiting http://maps.google.com/
+chrome.webRequest.onBeforeRedirect.addListener(wrap(async (details) => {
+  await storageReady;
+  if (!(await isMainTabUrl(details))) {
     return;
   }
   const requestInfo = requestMap[details.requestId];
   if (!requestInfo) {
     return;
   }
-  const tabInfo = requestInfo.tabInfo;
-  if (tabInfo.state == TAB_DELETED) {
-    return;
-  }
-  if (tabInfo.committed) {
-    throw "onCommitted before onSendHeaders!";
+  for (const [tabId, tabBorn] of Object.entries(requestInfo.tabIdToBorn)) {
+    const tabInfo = tabMap[tabId];
+    if (tabInfo?.born != tabBorn) {
+      continue;
+    }
+    if (tabInfo.committed) {
+      console.error("onCommitted before onBeforeRedirect!");
+      continue;
+    }
+    const parsed = parseUrl(details.redirectUrl);
+    tabInfo.setInitialDomain(requestInfo.id(), parsed.domain, parsed.origin);
   }
-  const parsed = parseUrl(details.url);
-  tabInfo.setInitialDomain(parsed.domain, parsed.origin);
-}, FILTER_ALL_URLS);
 
-chrome.webRequest.onResponseStarted.addListener(function (details) {
+}), FILTER_ALL_URLS);
+
+chrome.webRequest.onResponseStarted.addListener(wrap(async (details) => {
+  //debugLog("wR.oRS", details?.tabId, details?.url, details);
+  await storageReady;
   const requestInfo = requestMap[details.requestId];
-  if (!requestInfo ||
-      requestInfo.tabInfo.state == TAB_DELETED ||
-      requestInfo.tabInfo.accessDenied) {
+  if (!requestInfo) {
+    return;
+  }
+  const tabInfos = [];
+  for (const [tabId, tabBorn] of Object.entries(requestInfo.tabIdToBorn)) {
+    const tabInfo = tabMap[tabId];
+    if (tabInfo?.born != tabBorn) {
+      continue;
+    }
+    tabInfos.push(tabInfo);
+  }
+  if (!tabInfos.length) {
     return;
   }
   const parsed = parseUrl(details.url);
   if (!parsed.domain) {
     return;
   }
-  const addr = details.ip || "(no address)";
+
+  let addr = details.ip;
+  let fromCache = details.fromCache;
+
+  if (!fromCache) {
+    updateNAT64(parsed.domain, addr);
+  }
+
+  if (ipCache) {
+    // This runs on Firefox only.
+    if (addr) {
+      const cachedAddr = ipCache.lookupOrNew(parsed.domain);
+      const grew = !cachedAddr.addr;
+      cachedAddr.time = Date.now();
+      cachedAddr.addr = addr;
+      cachedAddr.save();
+      if (grew) {
+        ipCacheGrew();
+      }
+    } else {
+      const cachedAddr = ipCache[parsed.domain];
+      if (cachedAddr) {
+        fromCache = true;
+        addr = cachedAddr.addr;
+      }
+    }
+  }
+  addr = reformatForNAT64(addr) || "(no address)";
 
   let flags = parsed.ssl ? FLAG_SSL : FLAG_NOSSL;
   if (parsed.ws) {
     flags |= FLAG_WEBSOCKET;
   }
-  if (!details.fromCache) {
+  if (!fromCache) {
     flags |= FLAG_UNCACHED;
   }
-  if (requestInfo.domain) throw "Duplicate onResponseStarted!";
+  if (details.tabId > 0) {
+    flags |= FLAG_NOTWORKER;
+  }
+  if (requestInfo.domain) throw `Duplicate onResponseStarted: ${parsed.domain}`;
   requestInfo.domain = parsed.domain;
-  requestInfo.tabInfo.addDomain(parsed.domain, addr, flags);
-}, FILTER_ALL_URLS);
+  requestInfo.save();
+  for (const tabInfo of tabInfos) {
+    tabInfo.addDomain(parsed.domain, addr, flags);
+  }
+}), FILTER_ALL_URLS);
 
-function forgetRequest(details) {
-  const requestInfo = requestMap[details.requestId];
-  delete requestMap[details.requestId];
-  if (requestInfo && requestInfo.domain) {
-    requestInfo.tabInfo.disconnectDomain(requestInfo.domain);
-    requestInfo.domain = null;
+const forgetRequest = wrap(async (details) => {
+  await storageReady;
+  const requestInfo = requestMap.remove(details.requestId);
+  if (!requestInfo?.domain) {
+    return;
   }
-};
+  for (const [tabId, tabBorn] of Object.entries(requestInfo.tabIdToBorn)) {
+    const tabInfo = tabMap[tabId];
+    if (tabInfo?.born == tabBorn) {
+      tabInfo.domains[requestInfo.domain]?.countDown();
+    }
+  }
+});
 chrome.webRequest.onCompleted.addListener(forgetRequest, FILTER_ALL_URLS);
 chrome.webRequest.onErrorOccurred.addListener(forgetRequest, FILTER_ALL_URLS);
 
@@ -781,81 +1143,52 @@ chrome.webRequest.onErrorOccurred.addListener(forgetRequest, FILTER_ALL_URLS);
 //
 // Unless http://crbug.com/60758 gets resolved, the context menu's appearance
 // cannot vary based on content.
-const menuId = chrome.contextMenus.create({
-  title: "Look up on bgp.he.net",
-  // Scope the menu to text selection in our popup windows.
-  contexts: ["selection"],
-  documentUrlPatterns: [document.location.origin + "/popup.html"],
-  onclick: function(info) {
-    const text = info.selectionText;
-    if (IP_CHARS.test(text)) {
-      chrome.tabs.create({url: "https://bgp.he.net/ip/" + text});
-    } else if (DNS_CHARS.test(text)) {
-      chrome.tabs.create({url: "https://bgp.he.net/dns/" + text});
-    } else {
-      // Malformed selection; shake the popup content.
-      const tabId = /#(\d+)$/.exec(info.pageUrl);
-      if (tabId) {
-        popups.shake(Number(tabId[1]));
-      }
-    }
-  }
+const MENU_ID = "ipvfoo-lookup";
+
+chrome.contextMenus?.removeAll(() => {
+  chrome.contextMenus.create({
+    title: "Look up on bgp.he.net",
+    id: MENU_ID,
+    // Scope the menu to text selection in our popup windows.
+    contexts: ["selection"],
+    documentUrlPatterns: [chrome.runtime.getURL("popup.html")],
+  });
 });
 
-
-// -- Options Storage --
-
-const DEFAULT_OPTIONS = {
-  regularColorScheme: "darkfg",
-  incognitoColorScheme: "lightfg",
-};
-
-function setOptions(newOptions, onDone) {
-  const added = subtractSets(newOptions, options);
-  const removed = subtractSets(options, newOptions);
-  if (added.length > 0) {
-    throw "Unexpected options: " + added;
-  }
-  if (removed.length > 0) {
-    throw "Missing options: " + removed;
+chrome.contextMenus?.onClicked.addListener((info, tab) => {
+  if (info.menuItemId != MENU_ID) return;
+  const text = info.selectionText;
+  if (IP4_CHARS.test(text) || IP6_CHARS.test(text)) {
+    // bgp.he.net doesn't support dotted IPv6 addresses.
+    chrome.tabs.create({url: `https://bgp.he.net/ip/${reformatForNAT64(text, false)}`});
+  } else if (DNS_CHARS.test(text)) {
+    chrome.tabs.create({url: `https://bgp.he.net/dns/${text}`});
+  } else {
+    // Malformed selection; shake the popup content.
+    const tabId = /#(\d+)$/.exec(info.pageUrl);
+    if (tabId) {
+      popups.shake(Number(tabId[1]));
+    }
   }
-  chrome.storage.sync.set(newOptions, function() {
-    loadOptions(onDone);
-  });
-}
-
-function clearOptions(onDone) {
-  chrome.storage.sync.clear(function() {
-    loadOptions(onDone);
-  });
-}
+});
 
-function loadOptions(onDone) {
-  chrome.storage.sync.get(Object.keys(options), function(items) {
-    for (const option of Object.keys(options)) {
-      const optValue = items[option] || DEFAULT_OPTIONS[option];
-      if (optValue == options[option]) {
-        continue;
-      }
-      options[option] = optValue;
-
-      if (option.endsWith("ColorScheme")) {
-        for (const tabId of Object.keys(tabMap)) {
-          const tabInfo = tabMap[tabId];
-          if (tabInfo.color == option) {
-            tabInfo.refreshPageAction();
-          }
+watchOptions(async (optionsChanged) => {
+  await storageReady;
+  optionsChanged = new Set(optionsChanged);
+  for (const tabInfo of Object.values(tabMap)) {
+    let refreshPageAction = optionsChanged.has(tabInfo.color);
+    if (optionsChanged.has(NAT64_KEY)) {
+      for (const [domain, di] of Object.entries(tabInfo.domains)) {
+        const newAddr = reformatForNAT64(di.addr);
+        if (di.addr != newAddr) {
+          di.addr = newAddr;
+          tabInfo.pushOne(domain);
+          refreshPageAction = true;
         }
       }
     }
-
-    onDone();
-  });
-}
-
-// Use DEFAULT_OPTIONS until loading completes.
-window.options = {};
-for (const option of Object.keys(DEFAULT_OPTIONS)) {
-  options[option] = DEFAULT_OPTIONS[option];
-}
-loadOptions(function() {});
+    if (refreshPageAction) {
+      tabInfo.refreshPageAction();
+    }
+  }
+});
diff --git a/src/cached_arrow.png b/src/cached_arrow.png
index 546b8d8..d182e02 100644
Binary files a/src/cached_arrow.png and b/src/cached_arrow.png differ
diff --git a/src/common.js b/src/common.js
index 17c76d6..8ec25e9 100644
--- a/src/common.js
+++ b/src/common.js
@@ -14,9 +14,295 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+"use strict";
+
+// Requires 
+
+
\ No newline at end of file
diff --git a/src/detectdarkmode.js b/src/detectdarkmode.js
new file mode 100644
index 0000000..7091349
--- /dev/null
+++ b/src/detectdarkmode.js
@@ -0,0 +1,3 @@
+"use strict";
+const query = window.matchMedia('(prefers-color-scheme: dark)');
+chrome.runtime.sendMessage({darkModeOffscreen: query.matches});
\ No newline at end of file
diff --git a/src/gray_lock.png b/src/gray_lock.png
index 6209147..7d8e61c 100644
Binary files a/src/gray_lock.png and b/src/gray_lock.png differ
diff --git a/src/gray_schrodingers_lock.png b/src/gray_schrodingers_lock.png
index 1d14949..f2a38a8 100644
Binary files a/src/gray_schrodingers_lock.png and b/src/gray_schrodingers_lock.png differ
diff --git a/src/gray_unlock.png b/src/gray_unlock.png
index c59c02e..4f18dbc 100644
Binary files a/src/gray_unlock.png and b/src/gray_unlock.png differ
diff --git a/src/icon16_transparent.png b/src/icon16_transparent.png
new file mode 100644
index 0000000..28e4a6c
Binary files /dev/null and b/src/icon16_transparent.png differ
diff --git a/src/iputil.js b/src/iputil.js
new file mode 100644
index 0000000..7225b07
--- /dev/null
+++ b/src/iputil.js
@@ -0,0 +1,206 @@
+"use strict";
+
+const DOT = '.';
+const COLON = ':';
+const IPV6_PART_COUNT = 8;
+
+// Based on Guava ipStringToBytes.
+// Returns 32/4=8 hex digits for IPv4, 128/4=32 hex digits for IPv6.
+function parseIP(s) {
+  // Make a first pass to categorize the characters in this string.
+  let hasColon = false;
+  let hasDot = false;
+  for (const c of s) {
+    if (c == DOT) {
+      hasDot = true;
+    } else if (c == COLON) {
+      if (hasDot) {
+        throw "colon after dot";
+      }
+      hasColon = true;
+    } else if (!(parseInt(c, 16) >= 0)) {
+      throw "invalid digit";
+    }
+  }
+
+  // Now decide which address family to parse.
+  if (hasColon) {
+    if (hasDot) {
+      const lastColon = s.lastIndexOf(COLON);
+      const prefix = textToPackedIPv6(s.slice(0, lastColon) + ':0:0');
+      const suffix = textToPackedIPv4(s.slice(lastColon + 1));
+      return prefix.slice(0, 96/4) + suffix;
+    }
+    return textToPackedIPv6(s);
+  } else if (hasDot) {
+    return textToPackedIPv4(s);
+  }
+  throw "no colons or dots";
+}
+
+// The input is a /96 or /128 worth of hex digits.
+function formatIPv6(packed, with_dots = false) {
+  if (!(packed.length == 96/4 || packed.length == 128/4)) {
+    throw "bad length";
+  }
+  const hextets = new Array(IPV6_PART_COUNT);
+  for (let i = 0; i < IPV6_PART_COUNT; i++) {
+    hextets[i] = 4*i < packed.length ? parseInt(packed.substr(4*i, 4), 16) : 0;
+  }
+  let suffix = "";
+  if (with_dots) {
+    suffix = [(hextets[6] >> 8) & 0xff, hextets[6] & 0xff,
+              (hextets[7] >> 8) & 0xff, hextets[7] & 0xff].join('.');
+    // Format as :4:4, then replace the 3-character suffix.
+    hextets[6] = hextets[7] = 4;
+  }
+  compressLongestRunOfZeroes(hextets);
+  const text = hextetsToIPv6String(hextets);
+  if (with_dots) {
+    return text.slice(0, text.length-3) + suffix;
+  }
+  return text;
+}
+
+function formatIPv6WithDots(packed) {
+  return formatIPv6(packed, true);
+}
+
+// Based on Guava textToNumericFormatV4
+function textToPackedIPv4(s) {
+  const parts = s.split(DOT, 5);
+  if (parts.length != 4) {
+    throw "wrong number of octets";
+  }
+  var packed = "";
+  for (const p of parts) {
+    const octet = parseInt(p, 10);
+    if (!(octet < 256 && p == octet.toString(10))) {
+      throw "bad octet";
+    }
+    packed += (octet >> 4).toString(16) + (octet & 0xf).toString(16);
+  }
+  return packed;
+}
+
+// Based on Guava textToNumericFormatV6
+function textToPackedIPv6(s) {
+  // An address can have [2..8] colons.
+  let delimiterCount = 0;
+  for (const c of s) {
+    if (c == COLON) delimiterCount++;
+  }
+  if (delimiterCount < 2 || delimiterCount > IPV6_PART_COUNT) {
+    throw "incorrect number of parts";
+  }
+  let partsSkipped = IPV6_PART_COUNT - (delimiterCount + 1); // estimate; may be modified later
+  let hasSkip = false;
+  const slen = s.length;
+  for (let i = 0; i < slen - 1; i++) {
+    if (s.charAt(i) == COLON && s.charAt(i + 1) == COLON) {
+      if (hasSkip) {
+        throw "can't have more than one ::";
+      }
+      hasSkip = true;
+      partsSkipped++; // :: means we skipped an extra part in between the two delimiters.
+      if (i == 0) {
+        partsSkipped++; // Begins with ::, so we skipped the part preceding the first :
+      }
+      if (i == slen - 2) {
+        partsSkipped++; // Ends with ::, so we skipped the part after the last :
+      }
+    }
+  }
+  if (s.charAt(0) == COLON && s.charAt(1) != COLON) {
+    throw "^: requires ^::";
+  }
+  if (s.charAt(slen - 1) == COLON && s.charAt(slen - 2) != COLON) {
+    throw ":$ requires ::$";
+  }
+  if (hasSkip && partsSkipped <= 0) {
+    throw ":: must expand to at least one '0'";
+  }
+  if (!hasSkip && delimiterCount + 1 != IPV6_PART_COUNT) {
+    throw "incorrect number of parts";
+  }
+
+  // Iterate through the parts of the ip string.
+  // Invariant: start is always the beginning of a hextet, or the second ':' of the skip
+  // sequence "::"
+  let packed = "";
+  let start = 0;
+  if (s.charAt(0) == COLON) {
+    start = 1;
+  }
+  while (start < slen) {
+    let end = s.indexOf(COLON, start);
+    if (end == -1) {
+      end = slen;
+    }
+    if (s.charAt(start) == COLON) {
+      for (let i = 0; i < partsSkipped; i++) {
+        packed += '0000';
+      }
+    } else {
+      // Note: parseIP already verified that this string contains only hex digits.
+      const hextet = parseInt(s.slice(start, end), 16);
+      if (hextet > 0xffff) {
+        throw "hextet too large";
+      }
+      packed += hextet.toString(16).padStart(4, '0');
+    }
+    start = end + 1;
+  }
+  return packed;
+}
+
+// Based on Guava compressLongestRunOfZeroes
+function compressLongestRunOfZeroes(hextets) {
+  let bestRunStart = -1;
+  let bestRunLength = -1;
+  let runStart = -1;
+  for (let i = 0; i < hextets.length + 1; i++) {
+    if (i < hextets.length && hextets[i] == 0) {
+      if (runStart < 0) {
+        runStart = i;
+      }
+    } else if (runStart >= 0) {
+      const runLength = i - runStart;
+      if (runLength > bestRunLength) {
+        bestRunStart = runStart;
+        bestRunLength = runLength;
+      }
+      runStart = -1;
+    }
+  }
+  if (bestRunLength >= 2) {
+    for (let i = bestRunStart; i < bestRunStart + bestRunLength; i++) {
+      hextets[i] = -1;
+    }
+  }
+}
+
+// Based on Guava hextetsToIPv6String
+function hextetsToIPv6String(hextets) {
+  // While scanning the array, handle these state transitions:
+  //   start->num => "num"     start->gap => "::"
+  //   num->num   => ":num"    num->gap   => "::"
+  //   gap->num   => "num"     gap->gap   => ""
+  let out = "";
+  let lastWasNumber = false;
+  for (let i = 0; i < hextets.length; i++) {
+    const thisIsNumber = hextets[i] >= 0;
+    if (thisIsNumber) {
+      if (lastWasNumber) {
+        out += COLON;
+      }
+      out += hextets[i].toString(16);
+    } else {
+      if (i == 0 || lastWasNumber) {
+        out += COLON + COLON;
+      }
+    }
+    lastWasNumber = thisIsNumber;
+  }
+  return out;
+}
diff --git a/src/manifest.json b/src/manifest.json
index 4ee08cd..f34df1a 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,8 +1,7 @@
 {
   "name": "IPvFoo",
-  "manifest_version": 2,
-  "version": "1.44",
-  "minimum_chrome_version": "26",
+  "manifest_version": 3,
+  "version": "2.27",
   "description": "Display the server IP address, with a realtime summary of IPv4, IPv6, and HTTPS information across all page elements.",
   "homepage_url": "https://github.com/pmarks-net/ipvfoo",
   "icons": {
@@ -10,25 +9,24 @@
     "128": "icon128.png"
   },
   "background": {
-    "scripts": [ "common.js", "background.js" ]
+    "service_worker": "background.js"
   },
-  "page_action": {
-    "dummy": "http://crbug.com/86449"
+  "action": {
+    "default_icon": {
+      "16": "icon16_transparent.png"
+    }
   },
   "options_ui": {
-    "page": "options.html",
-    "chrome_style": true
+    "page": "options.html"
   },
   "permissions": [
     "contextMenus",
     "storage",
     "webNavigation",
     "webRequest",
-    ""
+    "offscreen"
   ],
-  "applications": {
-      "gecko": {
-          "id": "ipvfoo@pmarks.net"
-      }
-  }
+  "host_permissions": [
+    ""
+  ]
 }
diff --git a/src/manifest/README.md b/src/manifest/README.md
new file mode 100644
index 0000000..19524bb
--- /dev/null
+++ b/src/manifest/README.md
@@ -0,0 +1,7 @@
+IPvFoo uses a different manifest.json file for Chrome vs. Firefox.
+One must be copied to the parent directory:
+
+```
+cp firefox-manifest.json ../manifest.json
+cp chrome-manifest.json ../manifest.json
+```
diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json
new file mode 100644
index 0000000..f34df1a
--- /dev/null
+++ b/src/manifest/chrome-manifest.json
@@ -0,0 +1,32 @@
+{
+  "name": "IPvFoo",
+  "manifest_version": 3,
+  "version": "2.27",
+  "description": "Display the server IP address, with a realtime summary of IPv4, IPv6, and HTTPS information across all page elements.",
+  "homepage_url": "https://github.com/pmarks-net/ipvfoo",
+  "icons": {
+    "16": "icon16.png",
+    "128": "icon128.png"
+  },
+  "background": {
+    "service_worker": "background.js"
+  },
+  "action": {
+    "default_icon": {
+      "16": "icon16_transparent.png"
+    }
+  },
+  "options_ui": {
+    "page": "options.html"
+  },
+  "permissions": [
+    "contextMenus",
+    "storage",
+    "webNavigation",
+    "webRequest",
+    "offscreen"
+  ],
+  "host_permissions": [
+    ""
+  ]
+}
diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json
new file mode 100644
index 0000000..d2fbbdd
--- /dev/null
+++ b/src/manifest/firefox-manifest.json
@@ -0,0 +1,46 @@
+{
+  "name": "IPvFoo",
+  "manifest_version": 3,
+  "version": "2.27",
+  "description": "Display the server IP address, with a realtime summary of IPv4, IPv6, and HTTPS information across all page elements.",
+  "homepage_url": "https://github.com/pmarks-net/ipvfoo",
+  "icons": {
+    "16": "icon16.png",
+    "128": "icon128.png"
+  },
+  "background": {
+    "scripts": ["iputil.js", "common.js", "background.js"]
+  },
+  "browser_specific_settings": {
+    "gecko": {
+      "id": "ipvfoo@pmarks.net",
+      "strict_min_version": "115.0"
+    },
+    "gecko_android": {
+      "strict_min_version": "120.0"
+    }
+  },
+  "action": {
+    "default_icon": {
+      "16": "icon16_transparent.png"
+    }
+  },
+  "page_action": {
+    "default_icon": {
+      "16": "icon16_transparent.png"
+    }
+  },
+  "options_ui": {
+    "page": "options.html",
+    "browser_style": false
+  },
+  "permissions": [
+    "contextMenus",
+    "storage",
+    "webNavigation",
+    "webRequest"
+  ],
+  "host_permissions": [
+    ""
+  ]
+}
diff --git a/src/manifest/use_chrome_manifest.sh.bat b/src/manifest/use_chrome_manifest.sh.bat
new file mode 100755
index 0000000..419886d
--- /dev/null
+++ b/src/manifest/use_chrome_manifest.sh.bat
@@ -0,0 +1,10 @@
+:; # This part is a Linux shell script
+:; cd "$(dirname "$0")"
+:; pwd
+:; cp -v "chrome-manifest.json" "../manifest.json"
+:; exit
+
+@REM This part is a Windows batch file
+CD /D "%~dp0"
+COPY "chrome-manifest.json" "..\manifest.json"
+@PAUSE
diff --git a/src/manifest/use_firefox_manifest.sh.bat b/src/manifest/use_firefox_manifest.sh.bat
new file mode 100755
index 0000000..db27eb4
--- /dev/null
+++ b/src/manifest/use_firefox_manifest.sh.bat
@@ -0,0 +1,10 @@
+:; # This part is a Linux shell script
+:; cd "$(dirname "$0")"
+:; pwd
+:; cp -v "firefox-manifest.json" "../manifest.json"
+:; exit
+
+@REM This part is a Windows batch file
+CD /D "%~dp0"
+COPY "firefox-manifest.json" "..\manifest.json"
+@PAUSE
diff --git a/src/options.html b/src/options.html
index ac6dedb..f02b7d8 100644
--- a/src/options.html
+++ b/src/options.html
@@ -16,90 +16,96 @@
 limitations under the License.
 -->
 
-
-
+  
+  
+  
+  
+  
+  
 
 
 
-  
-

Color Scheme

- - - - - - - - - - - -
Regular tabs: - - - -
Incognito tabs: - - - -
-
+

NAT64 prefixes

+ Prefixes are discovered automatically when you visit these IPv4-only pages using a NAT64 connection: +
    +
    + + +
    null::/96
    - +
    - - diff --git a/src/options.js b/src/options.js index d536641..a4fe089 100644 --- a/src/options.js +++ b/src/options.js @@ -14,56 +14,63 @@ See the License for the specific language governing permissions and limitations under the License. */ -const bg = chrome.extension.getBackgroundPage(); -const colorSchemeOptions = ["regularColorScheme", "incognitoColorScheme"]; +"use strict"; -function disableAll(disabled) { - for (const e of document.getElementsByClassName("disabler")) { - e.disabled = disabled; - } -} +// Requires -
    -
    + +
    0 requests omitted
    +
    - diff --git a/src/popup.js b/src/popup.js index 9e6a8d6..ff1cffa 100644 --- a/src/popup.js +++ b/src/popup.js @@ -14,26 +14,94 @@ See the License for the specific language governing permissions and limitations under the License. */ -window.tabId = Number(window.location.hash.substr(1)); -if (!isFinite(tabId)) { +"use strict"; + +// Requires + + +see console + diff --git a/tests/tinytest.js b/tests/tinytest.js new file mode 100644 index 0000000..183d0eb --- /dev/null +++ b/tests/tinytest.js @@ -0,0 +1,91 @@ +/** + * Very simple in-browser unit-test library, with zero deps. + * + * Background turns green if all tests pass, otherwise red. + * View the JavaScript console to see failure reasons. + * + * Example: + * + * adder.js (code under test) + * + * function add(a, b) { + * return a + b; + * } + * + * adder-test.html (tests - just open a browser to see results) + * + * + * + * + * + * That's it. Stop using over complicated frameworks that get in your way. + * + * -Joe Walnes + * MIT License. See https://github.com/joewalnes/jstinytest/ + */ +const TinyTest = { + + run: function(tests) { + let failures = 0; + for (let testName in tests) { + let testAction = tests[testName]; + try { + testAction(); + console.log('Test:', testName, 'OK'); + } catch (e) { + failures++; + console.error('Test:', testName, 'FAILED', e); + console.error(e.stack); + } + } + setTimeout(function() { // Give document a chance to complete + if (window.document && document.body) { + document.body.style.backgroundColor = (failures == 0 ? '#99ff99' : '#ff9999'); + } + }, 0); + }, + + fail: function(msg) { + throw new Error('fail(): ' + msg); + }, + + assert: function(value, msg) { + if (!value) { + throw new Error('assert(): ' + msg); + } + }, + + assertEquals: function(expected, actual) { + if (expected != actual) { + throw new Error('assertEquals() "' + expected + '" != "' + actual + '"'); + } + }, + + assertStrictEquals: function(expected, actual) { + if (expected !== actual) { + throw new Error('assertStrictEquals() "' + expected + '" !== "' + actual + '"'); + } + }, + +}; + +const fail = TinyTest.fail, + assert = TinyTest.assert, + assertEquals = TinyTest.assertEquals, + eq = TinyTest.assertEquals, // alias for assertEquals + assertStrictEquals = TinyTest.assertStrictEquals, + tests = TinyTest.run;