From 9d6569cd656eff0f4e3132c5fa5e2c0fe60b7acf Mon Sep 17 00:00:00 2001 From: Gitoffthelawn Date: Sat, 19 Mar 2022 09:41:51 -0700 Subject: [PATCH 001/100] i18n AMO link (#47) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d47c87b..07b61c9 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,4 @@ https://chrome.google.com/webstore/detail/ecanpcehffngcegjmadlcijfolapggal ![Screenshot](/misc/screenshot_webstore_640x400.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. +IPvFoo now [runs on Firefox](https://addons.mozilla.org/firefox/addon/ipvfoo-pmarks/), but there are [a few bugs](https://github.com/pmarks-net/ipvfoo/issues/32) to work out. From 369cb4b6f25e896921e517913034b96f5ddc50a8 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 27 Sep 2022 16:35:56 -0400 Subject: [PATCH 002/100] Rewrite everything for Manifest V3. --- src/background.js | 1227 ++++++++++++++++++++++----------------------- src/common.js | 157 ++++++ src/manifest.json | 18 +- src/options.html | 78 +-- src/options.js | 83 ++- src/popup.js | 29 +- 6 files changed, 886 insertions(+), 706 deletions(-) diff --git a/src/background.js b/src/background.js index 2ceca40..c1c15b1 100644 --- a/src/background.js +++ b/src/background.js @@ -35,149 +35,49 @@ 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]}, -}; +importScripts("common.js"); -// 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], -}; +/* +(async () => { + const bootTime = Date.now(); + const key = `heartbeat/${bootTime}`; + while (true) { + const msg = `service_worker running for ${(Date.now()-bootTime)/1000}s`; + chrome.storage.local.set({[key]: msg}); + console.log(msg); + await sleep(10000); + } +})(); +*/ // 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 makeDead() +const TAB_ALIVE = 1; // Waiting for makeDead() +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), -}; - -// 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; -} - -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]); -} - -// 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 "?"; -} - function parseUrl(url) { let domain = null; 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 +89,524 @@ 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 -- - -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]; - }); -}; - -TabInfo.prototype.setInitialDomain = function(domain, origin) { - this.mainDomain = domain; - this.mainOrigin = origin; +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; + } + + id() { return this.#id; } + + 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; + } - // If anyone's watching, show some preliminary state. - popups.pushAll(this.tabId); -}; + // 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.local.remove(key); + return; + } + const j = JSON.stringify(this); + if (this.#savedJSON == j) { + return; + } + //console.log("saving", key, j); + await chrome.storage.local.set({[key]: j}); + this.#savedJSON = j; + } + } -TabInfo.prototype.setCommitted = function(domain, origin) { - if (this.state == TAB_DELETED) throw "Impossible"; + // No need to await. + async remove() { + this.#remove = true; + await this.save(); + } +} - const oldState = [this.accessDenied, this.mainDomain]; +class SaveableMap { + #factory; + #prefix; - 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; + constructor(factory, prefix) { + this.#factory = factory; + this.#prefix = prefix; } - this.mainDomain = domain; - this.dataExists = true; - this.committed = true; + validateId(id) { + const idNumeric = parseInt(id, 10); + if (!idNumeric) { + throw `malformed id: ${id}`; + } + return idNumeric; + } - // This is usually redundant, but lastPattern takes care of it. - this.updateIcon(); + 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; + } - // If the table contents changed, then redraw it. - const newState = [this.accessDenied, this.mainDomain]; - if (oldState.toString() != newState.toString()) { - popups.pushAll(this.tabId); + lookupOrNew(id) { + id = this.validateId(id); + let o = this[id]; + if (!o) { + o = this[id] = new this.#factory(this.#prefix, id); + } + return o; } -}; -// If the pageAction is supposed to be visible now, then draw it again. -TabInfo.prototype.refreshPageAction = function() { - this.lastPattern = ""; - this.lastTooltip = ""; - this.updateIcon(); -}; + remove(id) { + id = this.validateId(id); + const o = this[id]; + if (o) { + delete this[id]; + o.remove(); + } + return o; + } +} -TabInfo.prototype.addDomain = function(domain, addr, flags) { - if (this.state == TAB_DELETED) throw "Impossible"; +// -- TabInfo -- - const oldDomainInfo = this.domains[domain]; - let connCount = null; - flags |= FLAG_CONNECTED; +class TabInfo extends SaveableEntry { + born = Date.now(); // For TabTracker timeout. + mainDomain = ""; // Bare domain from the main_frame request. + mainOrigin = ""; // Origin from the main_frame request. + dataExists = false; // True if we have data to publish. + 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. + accessDenied = false; // webRequest events aren't permitted. + color = "regularColorScheme"; // ... or incognitoColorScheme. + + // 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(); + } + } - 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); - return; + afterLoad() { + for (const [domain, json] of Object.entries(this.domains)) { + this.domains[domain] = DomainInfo.fromJSON(this, domain, json); } - // Run this after the last connection goes away. - const that = this; - connCount = new ConnectionCounter(function() { - if (that.state == TAB_DELETED) { - return; - } - const d = that.domains[domain]; - if (d) { - d.flags &= ~FLAG_CONNECTED; - popups.pushOne(that.tabId, domain, d.addr, d.flags); - } - }); - 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) { + } + + tooYoungToDie() { + // Spare new tabs from garbage collection for a minute or so. + return (this.#state == TAB_BIRTH && + this.born >= Date.now() - 60 * 1000); + } + + makeAlive() { + if (this.#state != TAB_BIRTH) { return; } + this.#state = TAB_ALIVE; + this.updateIcon(); } - this.domains[domain] = { - addr: addr, - flags: flags, - connCount: connCount, - }; - this.dataExists = true; + makeDead() { + this.#state = TAB_DEAD; + } - this.updateIcon(); - popups.pushOne(this.tabId, domain, addr, flags); -}; + setInitialDomain(domain, origin) { + this.mainDomain = domain; + this.mainOrigin = origin; -TabInfo.prototype.disconnectDomain = function(domain) { - const d = this.domains[domain]; - if (d) { - d.connCount.down(); + // If anyone's watching, show some preliminary state. + this.pushAll(); + this.save(); } -}; -TabInfo.prototype.updateIcon = function() { - if (!(this.state == TAB_ALIVE && this.dataExists)) { - return; + setCommitted(domain, origin) { + const oldState = [this.accessDenied, this.mainDomain]; + + 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; + } + + this.mainDomain = domain; + this.dataExists = true; + this.committed = true; + + // This is usually redundant, but lastPattern takes care of it. + this.updateIcon(); + + // If the table contents changed, then redraw it. + const newState = [this.accessDenied, this.mainDomain]; + if (oldState.toString() != newState.toString()) { + 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(); } - 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"; + + addDomain(domain, addr, flags) { + if (!(flags & FLAG_CONNECTED)) { + throw "addDomain requires FLAG_CONNECTED"; + } + 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; + } + d = this.domains[domain] = + new DomainInfo(this, domain, addr || "(lost)", flags); + d.countUp(); } else { - switch (version) { - case "4": has4 = true; break; - case "6": has6 = true; break; + 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; } } - } - 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; + this.dataExists = true; + this.updateIcon(); + this.pushOne(domain); + this.save(); } - // Don't waste time redrawing the same icon. - if (this.lastPattern == pattern) { - return; - } - 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); -}; + updateIcon() { + if (!(this.#state == TAB_ALIVE && this.dataExists)) { + 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(); + tooltip = `${d.addr} - IPvFoo`; + } 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) { + chrome.action.setTitle({ + "tabId": this.id(), + "title": tooltip, + }); + this.lastTooltip = tooltip; + this.save(); + } -// 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]); + // Don't waste time redrawing the same icon. + if (this.lastPattern != pattern) { + const color = options[this.color]; + chrome.action.setIcon({ + "tabId": this.id(), + "imageData": { + "16": buildIcon(pattern, 16, color), + "32": buildIcon(pattern, 32, color), + }, + }); + chrome.action.setPopup({ + "tabId": this.id(), + "popup": `popup.html#${this.id()}`, + }); + this.lastPattern = pattern; + this.save(); } } - 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. + pushAll() { + popups.pushAll(this.id(), this.getTuples(), this.spillCount); + } -const ConnectionCounter = function(onZero) { - this.onZero = onZero; - this.count = 0; - this.timer = null; -}; + pushOne(domain) { + if (this.#state == TAB_DEAD) { + // This could happen if checkZero() hits a stale TabInfo. + return; + } + popups.pushOne(this.id(), this.getTuple(domain)); + } -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(); + // Build some [domain, addr, version, flags] tuples, for a popup. + getTuples() { + 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 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]); } - }, 500); + } + return tuples; } -}; -ConnectionCounter.prototype.down = function() { - if (!(this.count > 0)) throw "Count went negative!"; - if (--this.count == 0 && !this.timer) { - this.onZero(); + // Build [domain, addr, version, flags] tuple, for a popup. + getTuple(domain) { + const d = this.domains[domain]; + if (!d) { + throw `missing domain: ${domain}`; + } + return [domain, d.addr, d.addrVersion(), d.flags]; } -}; +} -// -- Popups -- +class DomainInfo { + tabInfo; + domain; + addr; + flags; -// 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? -}; + count = 0; // count of active requests + inhibitZero = false; -// 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(); -}; + constructor(tabInfo, domain, addr, flags) { + this.tabInfo = tabInfo; + this.domain = domain; + this.addr = addr; + this.flags = flags; + } -// Periodically make sure this.map is a subset of the visible popups. -Popups.prototype.garbageCollect = function() { - if (this.hasTimeout) { - return; + // count and FLAG_CONNECTED will be computed from requestMap. + toJSON() { + return [this.addr, this.flags & ~FLAG_CONNECTED]; } - if (Object.keys(this.map).length == 0) { - return; + + static fromJSON(tabInfo, domain, json) { + const [addr, flags] = json; + return new DomainInfo(tabInfo, domain, addr, flags); } - 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]; - } + + // 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. + addrVersion() { + if (this.addr) { + if (/^64:ff9b::/.test(this.addr)) return "4"; // RFC6052 + if (this.addr.indexOf(".") >= 0) return "4"; + if (this.addr.indexOf(":") >= 0) return "6"; } + return "?"; + } - // Maybe schedule another run. - that.hasTimeout = false; - that.garbageCollect(); - }, 5000); -}; + async countUp() { + 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(); + } + } -Popups.prototype.pushAll = function(tabId) { - const win = this.map[tabId]; - const tabInfo = tabMap[tabId]; - if (win && tabInfo) { - win.pushAll(tabInfo.getTuples(), tabInfo.spillCount); + countDown() { + if (!(this.count > 0)) throw "Count went negative!"; + --this.count; + this.#checkZero(); } -}; -Popups.prototype.pushOne = function(tabId, domain, addr, flags) { - const win = this.map[tabId]; - if (win) { - win.pushOne([domain, addr, addrToVersion(addr), flags]); + #checkZero() { + if (this.count == 0 && !this.inhibitZero) { + this.flags &= ~FLAG_CONNECTED; + this.tabInfo.pushOne(this.domain); + } } -}; +} -Popups.prototype.pushSpillCount = function(tabId, count) { - const win = this.map[tabId]; - if (win) { - win.pushSpillCount(count); +class RequestInfo extends SaveableEntry { + tabId = null; + tabBorn = null; + domain = null; + + afterLoad() { + const tabInfo = tabMap[this.tabId]; + if (tabInfo?.born != this.tabBorn) { + // In theory this shouldn't happen, because every request terminates + // with forgetRequest(), but MV3 probably adds chaos. + requestMap.remove(this.id()); + console.log("garbage-collected RequestInfo", this.id()); + return; + } + if (!this.domain) { + return; // still waiting for onResponseStarted + } + tabInfo.addDomain(this.domain, null, FLAG_CONNECTED); } -}; +} + +// tabId -> TabInfo +const tabMap = new SaveableMap(TabInfo, "tab/") -Popups.prototype.shake = function(tabId) { - const win = this.map[tabId]; - if (win) { - win.shake(); +// requestId -> {tabInfo, domain} +const requestMap = new SaveableMap(RequestInfo, "req/"); + +// Must "await storageReady;" before reading maps. +const storageReady = (async () => { + const items = await chrome.storage.local.get(); + await spriteImgReady; + await optionsReady; + + const unparseable = []; + for (const [k, v] of Object.entries(items)) { + if (!(tabMap.load(k, v) || requestMap.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(); + } +})(); + +// -- 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]; + }; + + pushAll(tabId, tuples, spillCount) { + this.ports[tabId]?.postMessage({ + cmd: "pushAll", + tuples: tuples, + spillCount: spillCount + }); + }; + + pushOne(tabId, tuple) { + this.ports[tabId]?.postMessage({ + cmd: "pushOne", + tuple: tuple + }); + }; + + 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(async (port) => { + await storageReady; + popups.attachPort(port); + port.onDisconnect.addListener(() => { + popups.detachPort(port); + }); +}); // -- TabTracker -- @@ -539,138 +622,71 @@ 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_(); -}; +class TabTracker { + tabSet = newMap(); // Set of all known tabIds -// 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); + constructor() { + chrome.tabs.onCreated.addListener((tab) => { + this.#addTab(tab.id, "onCreated"); + }); + chrome.tabs.onRemoved.addListener((tabId) => { + this.#removeTab(tabId, "onRemoved"); + }); + chrome.tabs.onReplaced.addListener((addId, removeId) => { + 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); + exists(tabId) { + return !!this.tabSet[tabId]; } - if (onDisconnect) { - onDisconnect(); - } -}; -// 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 * 1000); } - 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) { + 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) { + delete this.tabSet[tabId]; + if (tabMap[tabId]?.tooYoungToDie()) { + return; + } + tabMap.remove(tabId).makeDead(); + } +} const tabTracker = new TabTracker(); // -- webNavigation -- -chrome.webNavigation.onCommitted.addListener(function(details) { +chrome.webNavigation.onCommitted.addListener(async (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); }); @@ -679,7 +695,8 @@ chrome.webNavigation.onCommitted.addListener(function(details) { // 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(async (tabId, changeInfo, tab) => { + await storageReady; const tabInfo = tabMap[tabId]; if (tabInfo) { tabInfo.color = tab.incognito ? @@ -690,24 +707,33 @@ chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) { // -- webRequest -- -chrome.webRequest.onBeforeRequest.addListener(function (details) { +chrome.webRequest.onBeforeRequest.addListener(async (details) => { + await storageReady; if (!details.tabId || details.tabId == -1) { // This request isn't related to a tab. return; } - if (details.type == "main_frame") { + let tabInfo = null; + if (details.type == "main_frame" || details.type == "outermost_frame") { const parsed = parseUrl(details.url); - new TabInfo(details.tabId).setInitialDomain( - parsed.domain, parsed.origin); + tabMap.remove(details.tabId); + tabInfo = tabMap.lookupOrNew(details.tabId); + tabInfo.setInitialDomain(parsed.domain, parsed.origin); + } else { + tabInfo = tabMap[details.tabId]; + if (!tabInfo) { + return; + } } - const tabInfo = tabMap[details.tabId]; - if (!tabInfo) { - return; + const requestInfo = requestMap.lookupOrNew(details.requestId); + if (requestInfo.tabId && requestInfo.domain) { + // Can this actually happen? + console.error("duplicate request; connection count leak"); } - requestMap[details.requestId] = { - tabInfo: tabInfo, - domain: null, - }; + requestInfo.tabId = tabInfo.id(); + requestInfo.tabBorn = tabInfo.born; + requestInfo.domain = null; + requestInfo.save(); }, FILTER_ALL_URLS); // In the event of an HSTS redirect, the mainOrigin may change @@ -717,16 +743,18 @@ chrome.webRequest.onBeforeRequest.addListener(function (details) { // // 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") { +chrome.webRequest.onSendHeaders.addListener(async (details) => { + await storageReady; + if (!(details.type == "main_frame" || + details.type == "outermost_frame")) { return; } const requestInfo = requestMap[details.requestId]; if (!requestInfo) { return; } - const tabInfo = requestInfo.tabInfo; - if (tabInfo.state == TAB_DELETED) { + const tabInfo = tabMap[requestInfo.tabId]; + if (tabInfo?.born != requestInfo.tabBorn) { return; } if (tabInfo.committed) { @@ -736,11 +764,16 @@ chrome.webRequest.onSendHeaders.addListener(function (details) { tabInfo.setInitialDomain(parsed.domain, parsed.origin); }, FILTER_ALL_URLS); -chrome.webRequest.onResponseStarted.addListener(function (details) { +chrome.webRequest.onResponseStarted.addListener(async (details) => { + await storageReady; const requestInfo = requestMap[details.requestId]; - if (!requestInfo || - requestInfo.tabInfo.state == TAB_DELETED || - requestInfo.tabInfo.accessDenied) { + if (!requestInfo) { + return; + } + const tabInfo = tabMap[requestInfo.tabId]; + if (!tabInfo || + tabInfo.born != requestInfo.tabBorn || + tabInfo.accessDenied) { return; } const parsed = parseUrl(details.url); @@ -756,18 +789,24 @@ chrome.webRequest.onResponseStarted.addListener(function (details) { if (!details.fromCache) { flags |= FLAG_UNCACHED; } - if (requestInfo.domain) throw "Duplicate onResponseStarted!"; + flags |= FLAG_CONNECTED; + if (requestInfo.domain) throw `Duplicate onResponseStarted: ${parsed.domain}`; requestInfo.domain = parsed.domain; - requestInfo.tabInfo.addDomain(parsed.domain, addr, flags); + requestInfo.save(); + 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 = async (details) => { + await storageReady; + const requestInfo = requestMap.remove(details.requestId); + if (!requestInfo?.domain) { + return; } + const tabInfo = tabMap[requestInfo.tabId]; + if (tabInfo?.born != requestInfo.tabBorn) { + return; + } + tabInfo.domains[requestInfo.domain]?.countDown(); }; chrome.webRequest.onCompleted.addListener(forgetRequest, FILTER_ALL_URLS); chrome.webRequest.onErrorOccurred.addListener(forgetRequest, FILTER_ALL_URLS); @@ -781,81 +820,41 @@ 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)) { + 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])); + } } - 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((optionsChanged) => { + for (const option of optionsChanged) { + if (!option.endsWith("ColorScheme")) continue; + for (const [tabId, tabInfo] of Object.entries(tabMap)) { + if (tabInfo.color == option) { + tabInfo.refreshPageAction(); } } - - onDone(); - }); -} - -// Use DEFAULT_OPTIONS until loading completes. -window.options = {}; -for (const option of Object.keys(DEFAULT_OPTIONS)) { - options[option] = DEFAULT_OPTIONS[option]; -} -loadOptions(function() {}); + } +}); diff --git a/src/common.js b/src/common.js index 17c76d6..3b2c862 100644 --- a/src/common.js +++ b/src/common.js @@ -20,3 +20,160 @@ const FLAG_NOSSL = 0x2; const FLAG_UNCACHED = 0x4; const FLAG_CONNECTED = 0x8; const FLAG_WEBSOCKET = 0x10; + +// Returns an Object with no default properties. +function newMap() { + return Object.create(null); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +const spriteImg = {ready: false}; +const spriteImgReady = (async function() { + //await sleep(1000); + for (const size of [16, 32]) { + const response = await fetch(chrome.runtime.getURL(`sprites${size}.png`)); + const blob = await response.blob(); + spriteImg[size] = await createImageBitmap(blob); + } + spriteImg.ready = true; +})(); + +// Get a element of the given size. +const _canvasElements = newMap(); +function _getCanvasContext(size) { + let c = _canvasElements[size]; + if (!c) { + c = _canvasElements[size] = new OffscreenCanvas(size, size); + } + return c.getContext("2d", {willReadFrequently: true}); +} + +// 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]}, +}; + +// 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], +}; + +// pattern is 0..3 characters, each '4', '6', or '?'. +// size is 16 or 32. +// color is "lightfg" or "darkfg". +function buildIcon(pattern, size, color) { + if (!spriteImg.ready) throw "must await spriteImgReady!"; + 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; +} + +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]); +} + +const DEFAULT_OPTIONS = { + regularColorScheme: "darkfg", + incognitoColorScheme: "lightfg", +}; + +let _watchOptionsFunc = null; +const options = {ready: false}; +const optionsReady = (async function() { + const items = await chrome.storage.sync.get(); + for (const option of Object.keys(DEFAULT_OPTIONS)) { + options[option] = items.hasOwnProperty(option) ? + items[option] : DEFAULT_OPTIONS[option]; + } + options.ready = true; + if (_watchOptionsFunc) { + _watchOptionsFunc(Object.keys(options)); + } +})(); + +chrome.storage.sync.onChanged.addListener(function(changes) { + // changes = {option: {oldValue: x, newValue: y}} + if (!options.ready) return; + const optionsChanged = []; + for (const option of Object.keys(DEFAULT_OPTIONS)) { + const change = changes[option]; + if (!change) continue; + options[option] = change.hasOwnProperty("newValue") ? + change.newValue : DEFAULT_OPTIONS[option]; + optionsChanged.push(option); + } + if (_watchOptionsFunc && optionsChanged.length) { + _watchOptionsFunc(optionsChanged); + } +}); + +function watchOptions(f) { + if (_watchOptionsFunc) throw "redundant watchOptions!"; + _watchOptionsFunc = f; + if (options.ready) { + _watchOptionsFunc(Object.keys(options)); + } +} + +function setOptions(newOptions) { + console.log("setOptions", newOptions); + const toSet = {}; + for (const option of Object.keys(DEFAULT_OPTIONS)) { + if (newOptions[option] != options[option]) { + toSet[option] = newOptions[option]; + } + } + if (Object.keys(toSet).length == 0) { + return false; // no change + } + chrome.storage.sync.set(toSet); + return true; // caller should wait for watchOptions() +} diff --git a/src/manifest.json b/src/manifest.json index 4ee08cd..85da135 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.0", "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,20 +9,19 @@ "128": "icon128.png" }, "background": { - "scripts": [ "common.js", "background.js" ] - }, - "page_action": { - "dummy": "http://crbug.com/86449" + "service_worker": "background.js" }, + "action": {}, "options_ui": { - "page": "options.html", - "chrome_style": true + "page": "options.html" }, "permissions": [ "contextMenus", "storage", "webNavigation", - "webRequest", + "webRequest" + ], + "host_permissions": [ "" ], "applications": { diff --git a/src/options.html b/src/options.html index ac6dedb..c2c3735 100644 --- a/src/options.html +++ b/src/options.html @@ -16,38 +16,50 @@ limitations under the License. --> - - + + + + @@ -99,7 +111,5 @@

Color Scheme

- - diff --git a/src/options.js b/src/options.js index d536641..8e42361 100644 --- a/src/options.js +++ b/src/options.js @@ -14,56 +14,55 @@ See the License for the specific language governing permissions and limitations under the License. */ -const bg = chrome.extension.getBackgroundPage(); -const colorSchemeOptions = ["regularColorScheme", "incognitoColorScheme"]; +// Requires -
@@ -113,5 +114,4 @@
- diff --git a/src/popup.js b/src/popup.js index ce612b3..63942ec 100644 --- a/src/popup.js +++ b/src/popup.js @@ -160,14 +160,13 @@ function makeRow(isFirst, tuple) { tr.className = "mainRow"; } - // Border for the "zeroth" column. - const sslBorder = document.createElement("span"); - sslBorder.className = "sslBorder"; + // Build the SSL icon for the "zeroth" pseudo-column. + const sslImg = makeSslImg(flags); + sslImg.className = "sslImg"; // Build the "Domain" column. const domainTd = document.createElement("td"); - domainTd.appendChild(makeSslImg(flags)); - domainTd.appendChild(sslBorder); + domainTd.appendChild(sslImg); domainTd.appendChild(document.createTextNode(domain)); domainTd.className = "domainTd"; domainTd.onclick = handleClick; From 6b6f41730e49e240fef07a7cb7ece4397eadc233 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Mon, 14 Aug 2023 20:59:15 -0400 Subject: [PATCH 034/100] Version bump to 2.8 I probably won't release this for Chrome, since the only relevant changes are for Firefox. --- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 1c232e5..7a4592c 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.7.999", + "version": "2.8", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 1c232e5..7a4592c 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.7.999", + "version": "2.8", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 54ee929..9d7ee09 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.7.999", + "version": "2.8", "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": { From 109b0c305ecafc407528fd5fa9c5dee92cbb54f2 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Mon, 14 Aug 2023 21:46:12 -0400 Subject: [PATCH 035/100] Keep icon in the Firefox address bar. --- src/background.js | 13 ++++++++++--- src/manifest/firefox-manifest.json | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/background.js b/src/background.js index 6b5741f..4b4ae07 100644 --- a/src/background.js +++ b/src/background.js @@ -365,9 +365,13 @@ class TabInfo extends SaveableEntry { if (has4) pattern += "4"; if (has6) pattern += "6"; + // Firefox might drop support for pageAction someday, but until then + // let's keep the icon in the address bar. + const action = chrome.pageAction || chrome.action; + // Don't waste time rewriting the same tooltip. if (this.lastTooltip != tooltip) { - chrome.action.setTitle({ + action.setTitle({ "tabId": this.id(), "title": tooltip, }); @@ -378,17 +382,20 @@ class TabInfo extends SaveableEntry { // Don't waste time redrawing the same icon. if (this.lastPattern != pattern) { const color = options[this.color]; - chrome.action.setIcon({ + action.setIcon({ "tabId": this.id(), "imageData": { "16": buildIcon(pattern, 16, color), "32": buildIcon(pattern, 32, color), }, }); - chrome.action.setPopup({ + action.setPopup({ "tabId": this.id(), "popup": `popup.html#${this.id()}`, }); + if (action.show) { + action.show(this.id()); // Firefox only + } this.lastPattern = pattern; this.save(); } diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 9d7ee09..509f1a2 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -16,7 +16,7 @@ "id": "ipvfoo@pmarks.net" } }, - "action": { + "page_action": { "default_icon": { "16": "icon16_transparent.png" } From 5a6605cf24549a4a4f1362c52c555819600b5042 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 00:22:02 -0400 Subject: [PATCH 036/100] Make Firefox scrollbars a bit less janky. --- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- src/popup.js | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 7a4592c..52a3d35 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.8", + "version": "2.9", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 7a4592c..52a3d35 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.8", + "version": "2.9", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 509f1a2..a2ecfab 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.8", + "version": "2.9", "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": { diff --git a/src/popup.js b/src/popup.js index 63942ec..6be4080 100644 --- a/src/popup.js +++ b/src/popup.js @@ -81,6 +81,7 @@ function pushOne(tuple) { } // No exact match. Insert the row in alphabetical order. table.insertBefore(makeRow(false, tuple), insertHere); + scrollbarHack(); } // Count must be a number. @@ -89,6 +90,7 @@ function pushSpillCount(count) { count == 0 ? "none" : "block"; removeChildren(document.getElementById("spill_count")).appendChild( document.createTextNode(count)); + scrollbarHack(); } // Shake the content (for 500ms) to signal an error. @@ -99,6 +101,19 @@ function shake() { }, 600); } +// Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1395025 +function scrollbarHack() { + if (typeof browser == "undefined") { + return; // nothing to do on Chrome. + } + setTimeout(() => { + const e = document.documentElement; + if (e.scrollHeight > e.clientHeight) { + document.body.style.paddingRight = '20px'; + } + }, 20); +} + function removeChildren(n) { while (n.hasChildNodes()) { n.removeChild(n.lastChild); From aa6694e37b2f076047d84408496f987225534936 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 01:00:51 -0400 Subject: [PATCH 037/100] Make Firefox link more prominent. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0048348..5ddf315 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Everything is captured privately using the webRequest API, without creating any ## Add to Chrome https://chrome.google.com/webstore/detail/ipvfoo/ecanpcehffngcegjmadlcijfolapggal +## Add to Firefox +https://addons.mozilla.org/firefox/addon/ipvfoo-pmarks/ + ## Screenshot ![Screenshot](/misc/screenshot_webstore_1_640x400.png?raw=true) - -## Firefox Support -IPvFoo now [runs on Firefox](https://addons.mozilla.org/firefox/addon/ipvfoo-pmarks/), but there are [a few bugs](https://github.com/pmarks-net/ipvfoo/issues/32) to work out. From 21da8c29115dc012faa9e61646d3d195c06028f6 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 10:47:13 -0400 Subject: [PATCH 038/100] Tweak Firefox scrollbarHack a bit. --- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- src/popup.js | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 52a3d35..22426e1 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.9", + "version": "2.10", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 52a3d35..22426e1 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.9", + "version": "2.10", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index a2ecfab..548ed00 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.9", + "version": "2.10", "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": { diff --git a/src/popup.js b/src/popup.js index 6be4080..1e0e199 100644 --- a/src/popup.js +++ b/src/popup.js @@ -102,6 +102,7 @@ function shake() { } // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1395025 +let redrawn = false; function scrollbarHack() { if (typeof browser == "undefined") { return; // nothing to do on Chrome. @@ -110,8 +111,11 @@ function scrollbarHack() { const e = document.documentElement; if (e.scrollHeight > e.clientHeight) { document.body.style.paddingRight = '20px'; + } else if (!redrawn) { + document.body.classList.toggle('force-redraw'); + redrawn = true; } - }, 20); + }, 200); } function removeChildren(n) { From 1f7c75fc5f68db4bc3963478c1c1e34dddd44991 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 14:37:26 -0400 Subject: [PATCH 039/100] Revert to the newMap() function. Apparently Map has a completely different API, and it's not worth doing a proper migration now. --- src/background.js | 44 ++++++++++++++---------------- src/common.js | 13 ++++++++- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/background.js b/src/background.js index 4b4ae07..87c15b3 100644 --- a/src/background.js +++ b/src/background.js @@ -219,7 +219,7 @@ class TabInfo extends SaveableEntry { mainDomain = ""; // Bare domain from the main_frame request. mainOrigin = ""; // Origin from the main_frame request. committed = false; // True if onCommitted has fired. - domains = new Map(); // Updated whenever we get some IPs. + 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. @@ -235,7 +235,7 @@ class TabInfo extends SaveableEntry { if (!options.ready) throw "must await optionsReady!"; if (tabMap[tabId]) throw "Duplicate entry in tabMap"; - if (tabTracker.exists(tabId)) { + if (tabTracker.tabSet.has(tabId)) { this.makeAlive(); } } @@ -264,7 +264,7 @@ class TabInfo extends SaveableEntry { remove() { super.remove(); // no await this.#state = TAB_DEAD; - this.domains = new Map(); + this.domains = newMap(); updateOriginMap(this.id(), this.mainOrigin, null); } @@ -504,16 +504,10 @@ class DomainInfo { class RequestInfo extends SaveableEntry { // Typically this contains one {tabId: tabBorn} entry, // but for Service Worker requests there may be multiple tabs. - tabIdToBorn = new Map(); + tabIdToBorn = newMap(); domain = null; afterLoad() { - try { - this.tabIdToBorn = new Map(Object.entries(this.tabIdToBorn)); - } catch { - // This will be garbage collected shortly. - this.tabIdToBorn = new Map(); - } for (const [tabId, tabBorn] of Object.entries(this.tabIdToBorn)) { const tabInfo = tabMap[tabId]; if (tabInfo?.born != tabBorn) { @@ -525,7 +519,7 @@ class RequestInfo extends SaveableEntry { } tabInfo.addDomain(this.domain, null, 0); } - if (!this.tabIdToBorn.size) { + if (Object.keys(this.tabIdToBorn).length == 0) { requestMap.remove(this.id()); console.log("garbage-collected RequestInfo", this.id()); return; @@ -540,7 +534,7 @@ const tabMap = new SaveableMap(TabInfo, "tab/") const requestMap = new SaveableMap(RequestInfo, "req/"); // mainOrigin -> Set of tabIds, for tabless service workers. -const originMap = new Map(); +const originMap = newMap(); function updateOriginMap(tabId, oldOrigin, newOrigin) { if (oldOrigin && oldOrigin != newOrigin) { @@ -567,7 +561,8 @@ function lookupOriginMap(origin) { } // Must "await storageReady;" before reading maps. -const storageReady = (async () => { +// You can force initStorage() from the console for debugging purposes. +const initStorage = async () => { await spriteImgReady; await optionsReady; @@ -581,6 +576,10 @@ const storageReady = (async () => { } } + // These are be no-ops unless initStorage() is called manually. + clearMap(tabMap); + clearMap(requestMap); + const items = await chrome.storage.session.get(); const unparseable = []; for (const [k, v] of Object.entries(items)) { @@ -598,14 +597,15 @@ const storageReady = (async () => { for (const requestInfo of Object.values(requestMap)) { requestInfo.afterLoad(); } -})(); +}; +const storageReady = initStorage(); // -- Popups -- // This class keeps track of the visible popup windows, // and streams changes to them as they occur. class Popups { - ports = new Map(); // tabId -> Port + ports = newMap(); // tabId -> Port // Attach a new popup window, and start sending it updates. attachPort(port) { @@ -684,7 +684,7 @@ chrome.runtime.onInstalled.addListener(async () => { // 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. class TabTracker { - tabSet = new Map(); // Set of all known tabIds + tabSet = new Set(); // Set of all known tabIds constructor() { chrome.tabs.onCreated.addListener(async (tab) => { @@ -703,22 +703,18 @@ class TabTracker { this.#pollAllTabs(); } - exists(tabId) { - return !!this.tabSet[tabId]; - } - // 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 = new Map(); + this.tabSet.clear(); for (const tab of result) { this.#addTab(tab.id, "pollAlltabs") } for (const tabId of Object.keys(tabMap)) { - if (!this.tabSet[tabId]) { + if (!this.tabSet.has(tabId)) { this.#removeTab(tabId, "pollAllTabs"); } } @@ -728,13 +724,13 @@ class TabTracker { #addTab(tabId, logText) { debugLog("addTab", tabId, logText); - this.tabSet[tabId] = true; + this.tabSet.add(tabId); tabMap[tabId]?.makeAlive(); } #removeTab(tabId, logText) { debugLog("removeTab", tabId, logText); - delete this.tabSet[tabId]; + this.tabSet.delete(tabId); if (tabMap[tabId]?.tooYoungToDie()) { return; } diff --git a/src/common.js b/src/common.js index 26f4290..a4e6393 100644 --- a/src/common.js +++ b/src/common.js @@ -24,6 +24,17 @@ const FLAG_CONNECTED = 0x8; const FLAG_WEBSOCKET = 0x10; const FLAG_NOTWORKER = 0x20; // from a tab, not a service worker +// Returns an Object with no default properties. +function newMap() { + return Object.create(null); +} + +function clearMap(m) { + for (const k of Object.keys(m)) { + delete m[k]; + } +} + function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -58,7 +69,7 @@ function redFailImg() { } // Get a element of the given size. -const _canvasElements = new Map(); +const _canvasElements = newMap(); function _getCanvasContext(size) { let c = _canvasElements[size]; if (!c) { diff --git a/src/manifest.json b/src/manifest.json index 22426e1..8ac60d5 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.10", + "version": "2.10.999", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 22426e1..8ac60d5 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.10", + "version": "2.10.999", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 548ed00..1cd67d8 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.10", + "version": "2.10.999", "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": { From 5630e8e80749eadb94a0fdef64aff64ee4d01a2f Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 15:47:56 -0400 Subject: [PATCH 040/100] Cache IP addresses in RAM on Firefox only. --- src/background.js | 76 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/src/background.js b/src/background.js index 87c15b3..b8af925 100644 --- a/src/background.js +++ b/src/background.js @@ -168,11 +168,16 @@ class SaveableMap { } validateId(id) { - const idNumeric = parseInt(id, 10); - if (!idNumeric) { - throw `malformed id: ${id}`; + if (this.#prefix == "ip/") { + // Don't restrict ipCache domain name keys. + return id; + } else { + const idNumeric = parseInt(id, 10); + if (idNumeric) { + return idNumeric; + } } - return idNumeric; + throw `malformed id: ${id}`; } load(key, savedJSON) { @@ -527,12 +532,41 @@ class RequestInfo extends SaveableEntry { } } +class IPCacheEntry extends SaveableEntry { + time = 0; + addr = ""; +} + // tabId -> TabInfo const tabMap = new SaveableMap(TabInfo, "tab/") // requestId -> {tabInfo, domain} const requestMap = new SaveableMap(RequestInfo, "req/"); +// 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; + } + // 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; + } + } +} + // mainOrigin -> Set of tabIds, for tabless service workers. const originMap = newMap(); @@ -583,7 +617,7 @@ const initStorage = async () => { 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))) { + if (!(tabMap.load(k, v) || requestMap.load(k, v) || ipCache?.load(k, v))) { unparseable.push(k); } } @@ -597,6 +631,9 @@ const initStorage = async () => { for (const requestInfo of Object.values(requestMap)) { requestInfo.afterLoad(); } + if (ipCache) { + ipCacheSize = Object.keys(ipCache).length; + } }; const storageReady = initStorage(); @@ -742,6 +779,9 @@ const tabTracker = new TabTracker(); // Workaround for http://crbug.com/1316588 (async function lostEventsWatchdog() { + if (typeof browser != "undefined") { + return; // Don't run this on Firefox. + } let quietCount = 0; while (true) { // This service worker doesn't usually live longer than 30 seconds, @@ -928,13 +968,35 @@ chrome.webRequest.onResponseStarted.addListener(async (details) => { if (!parsed.domain) { return; } - const addr = details.ip || "(no address)"; + + let addr = details.ip; + let fromCache = details.fromCache; + 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 = 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 (details.tabId > 0) { From 8fb4ba3db45485a4f0afa7bdfa54c1e4994478ed Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 15:54:13 -0400 Subject: [PATCH 041/100] Version bump to 2.11 --- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 8ac60d5..d3b1633 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.10.999", + "version": "2.11", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 8ac60d5..d3b1633 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.10.999", + "version": "2.11", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 1cd67d8..a642a14 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.10.999", + "version": "2.11", "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": { From 2a305a5d070b9043125dc5a595829796c6db4259 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 16:10:53 -0400 Subject: [PATCH 042/100] Revert tabTracker to newMap() as well. --- src/background.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/background.js b/src/background.js index b8af925..51d03fc 100644 --- a/src/background.js +++ b/src/background.js @@ -240,7 +240,7 @@ class TabInfo extends SaveableEntry { if (!options.ready) throw "must await optionsReady!"; if (tabMap[tabId]) throw "Duplicate entry in tabMap"; - if (tabTracker.tabSet.has(tabId)) { + if (tabTracker.exists(tabId)) { this.makeAlive(); } } @@ -721,7 +721,7 @@ chrome.runtime.onInstalled.addListener(async () => { // 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. class TabTracker { - tabSet = new Set(); // Set of all known tabIds + tabSet = newMap(); // Set of all known tabIds constructor() { chrome.tabs.onCreated.addListener(async (tab) => { @@ -740,18 +740,22 @@ class TabTracker { this.#pollAllTabs(); } + exists(tabId) { + return !!this.tabSet[tabId]; + } + // 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.clear(); + this.tabSet = newMap(); for (const tab of result) { this.#addTab(tab.id, "pollAlltabs") } for (const tabId of Object.keys(tabMap)) { - if (!this.tabSet.has(tabId)) { + if (!this.tabSet[tabId]) { this.#removeTab(tabId, "pollAllTabs"); } } @@ -761,13 +765,13 @@ class TabTracker { #addTab(tabId, logText) { debugLog("addTab", tabId, logText); - this.tabSet.add(tabId); + this.tabSet[tabId] = true; tabMap[tabId]?.makeAlive(); } #removeTab(tabId, logText) { debugLog("removeTab", tabId, logText); - this.tabSet.delete(tabId); + delete this.tabSet[tabId]; if (tabMap[tabId]?.tooYoungToDie()) { return; } From 23295d10577bc484b8aac2f439110cf2cb05556a Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 18:30:40 -0400 Subject: [PATCH 043/100] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5ddf315..82a06e1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**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 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. Everything is captured privately using the webRequest API, without creating any additional network traffic. From 9ca99c4c7a2b5a4fae9668f1182f73c5f860e3fd Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 18:31:43 -0400 Subject: [PATCH 044/100] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 82a06e1..633a26d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**IPvFoo** is a Chrome/Firefox 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 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. Everything is captured privately using the webRequest API, without creating any additional network traffic. From 888cbfc46a6f323b5117a3ebae805838efe33d48 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 15 Aug 2023 20:30:12 -0400 Subject: [PATCH 045/100] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 633a26d..53b9d5c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**IPvFoo** is a Chrome/Firefox extension that adds an icon 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, without creating any additional network traffic. From f090549c1c18efbeb8dabe4949dd5189e58d240c Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Wed, 16 Aug 2023 14:03:52 -0400 Subject: [PATCH 046/100] Requires Firefox 115 for storage.session API --- src/manifest/firefox-manifest.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index a642a14..9c6e587 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.11", + "version": "2.11.1", "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": { @@ -13,7 +13,8 @@ }, "browser_specific_settings": { "gecko": { - "id": "ipvfoo@pmarks.net" + "id": "ipvfoo@pmarks.net", + "strict_min_version": "115.0" } }, "page_action": { From 178a642c705e3cf06a153e4c04def946c42eb1c2 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Wed, 16 Aug 2023 15:18:05 -0400 Subject: [PATCH 047/100] Add "Click to grant permission" button. This seems necessary on Firefox, though it can be manually triggered on either browser. --- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- src/popup.html | 7 +++++++ src/popup.js | 21 ++++++++++++++++++++- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index d3b1633..f054f82 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.11", + "version": "2.12", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index d3b1633..f054f82 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.11", + "version": "2.12", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 9c6e587..9b00628 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.11.1", + "version": "2.12", "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": { diff --git a/src/popup.html b/src/popup.html index daf7ab3..57b619b 100644 --- a/src/popup.html +++ b/src/popup.html @@ -81,6 +81,12 @@ float: left; padding: 6px; } +#beg { + display: none; + margin-bottom: 10px; + padding: 10px; + width: 100% +} #spill_count_container { text-align: center; font-weight: bold; @@ -108,6 +114,7 @@
+
0 requests omitted diff --git a/src/popup.js b/src/popup.js index 1e0e199..af2b2de 100644 --- a/src/popup.js +++ b/src/popup.js @@ -16,6 +16,8 @@ limitations under the License. "use strict"; +const ALL_URLS = ""; + const tabId = window.location.hash.substr(1); if (!isFinite(Number(tabId))) { throw "Bad tabId"; @@ -23,12 +25,29 @@ if (!isFinite(Number(tabId))) { let table = null; -window.onload = function() { +window.onload = async function() { table = document.getElementById("addr_table"); table.onmousedown = handleMouseDown; + await beg(); connectToExtension(); }; +async function beg() { + const p = await chrome.permissions.getAll(); + for (const origin of p.origins) { + if (origin == ALL_URLS) { + return; // We already have permission. + } + } + const button = document.getElementById("beg"); + button.style.display = "block"; // visible + button.addEventListener("click", async () => { + if (await chrome.permissions.request({origins: [ALL_URLS]})) { + button.style.display = "none"; + } + }); +} + function connectToExtension() { const port = chrome.runtime.connect(null, {name: tabId}); port.onMessage.addListener((msg) => { From 1749a09f568b3707c127486b431c18bfcd5edcf7 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Thu, 17 Aug 2023 21:55:30 -0400 Subject: [PATCH 048/100] Yet another Firefox bug workaround. On Windows, the permission dialog was rendering *under* the popup, so we have to call window.close() explicitly. --- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- src/popup.js | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index f054f82..10f1613 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.12", + "version": "2.13", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index f054f82..10f1613 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.12", + "version": "2.13", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 9b00628..d8c547b 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.12", + "version": "2.13", "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": { diff --git a/src/popup.js b/src/popup.js index af2b2de..7ae6b69 100644 --- a/src/popup.js +++ b/src/popup.js @@ -42,9 +42,12 @@ async function beg() { const button = document.getElementById("beg"); button.style.display = "block"; // visible button.addEventListener("click", async () => { - if (await chrome.permissions.request({origins: [ALL_URLS]})) { - button.style.display = "none"; - } + // We need to close the popup before awaiting, otherwise + // Firefox (at least version 116 on Windows) renders the + // permission dialog underneath the popup. + const promise = chrome.permissions.request({origins: [ALL_URLS]}); + window.close(); + await promise; }); } From d8d96059a67a63c1c07f23c3bd9bf6e8bdd85f40 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Sat, 19 Aug 2023 13:09:20 -0400 Subject: [PATCH 049/100] Add a screenshot for Microsoft Edge. --- misc/screenshot_edge_toolbar_1280x800.png | Bin 0 -> 72150 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 misc/screenshot_edge_toolbar_1280x800.png diff --git a/misc/screenshot_edge_toolbar_1280x800.png b/misc/screenshot_edge_toolbar_1280x800.png new file mode 100644 index 0000000000000000000000000000000000000000..f2de3dd3c28b53ad534e79641e588e465641de58 GIT binary patch literal 72150 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 literal 0 HcmV?d00001 From c49f5954cf70737d1200036852b99ab9cce3f576 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Mon, 21 Aug 2023 13:22:37 -0400 Subject: [PATCH 050/100] Add Firefox permission screenshot --- ...shot-firefox1.png => screenshot_firefox.png} | Bin misc/screenshot_firefox_permission.png | Bin 0 -> 63981 bytes 2 files changed, 0 insertions(+), 0 deletions(-) rename misc/{screenshot-firefox1.png => screenshot_firefox.png} (100%) create mode 100644 misc/screenshot_firefox_permission.png 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_permission.png b/misc/screenshot_firefox_permission.png new file mode 100644 index 0000000000000000000000000000000000000000..d84273650c6f245be6cdfc6349febe4b8a5e9bd1 GIT binary patch literal 63981 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�s>m5rhD!kTiJ4C%@K6U$dU%qpi!Hlbc7$6 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 literal 0 HcmV?d00001 From 22793e24db991e20741873a871dcd98d1b99c08e Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Mon, 28 Aug 2023 10:55:26 -0400 Subject: [PATCH 051/100] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 53b9d5c..1d12e68 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,9 @@ https://chrome.google.com/webstore/detail/ipvfoo/ecanpcehffngcegjmadlcijfolapgga ## Add to Firefox https://addons.mozilla.org/firefox/addon/ipvfoo-pmarks/ +## Add to Edge +https://microsoftedge.microsoft.com/addons/detail/ipvfoo/dphnkggpaicipkljebciobedeiaiofod +*(You can also run the Chrome version on Edge, as they are identical.)* + ## Screenshot ![Screenshot](/misc/screenshot_webstore_1_640x400.png?raw=true) From 7c8cc9b8c4d712b2852b4b1f0ab2babb9d9cd8f1 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Mon, 28 Aug 2023 12:08:01 -0400 Subject: [PATCH 052/100] Clarify the language in privacy_policy.txt --- misc/privacy_policy.txt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/misc/privacy_policy.txt b/misc/privacy_policy.txt index 82c6e4e..fcb7734 100644 --- a/misc/privacy_policy.txt +++ b/misc/privacy_policy.txt @@ -1,11 +1,12 @@ 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: From c66883727759bf225117d80ab41f2d6710d6cb15 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Mon, 11 Sep 2023 16:16:51 -0400 Subject: [PATCH 053/100] Add dark mode. --- src/1x1_808080.png | Bin 0 -> 4752 bytes src/1x1_888.png | Bin 91 -> 0 bytes src/cached_arrow.png | Bin 236 -> 4929 bytes src/gray_lock.png | Bin 167 -> 160 bytes src/gray_schrodingers_lock.png | Bin 194 -> 181 bytes src/gray_unlock.png | Bin 170 -> 158 bytes src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- src/options.html | 9 +++++++++ src/popup.html | 31 ++++++++++++++++++++++++++--- src/serviceworker.png | Bin 371 -> 6677 bytes 12 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 src/1x1_808080.png delete mode 100644 src/1x1_888.png diff --git a/src/1x1_808080.png b/src/1x1_808080.png new file mode 100644 index 0000000000000000000000000000000000000000..c918a96ec6b3d631444f8df5878cb198e8e5304c GIT binary patch literal 4752 zcmeHKc~BE+77rerGN=f+o^1ffqdMuFq$2@^potoc5D`JePNx$Z$YByl07XWD5f4z5 z)mcYG9dQ+PJa7d;ju9_lT@+nQ@V>xNKph=t6mh=F6bi*zEE0x*UmMHE-WGh1cdO2%PzJn6l7$;XFcYNHYn5ai0T~l@1Vorg zC52*cY~7~LX@Z7id^D^ZSM@I70dHj8Ty6csw}YpBC*kf)Xo}jId6RPf)zsZ~BJNoK z^6b~n9=Y{DJlQZO z(1_NgqyB!AlAd1JQvX<_6mAHvNUQPoTH%*JCqJ*DQSb1Pm;Qm*^y~x6nXy0PU{S-A zD5@s=?avpN`usNRNuY>(jk>}X`o&W`g+G|%aP^zUuqf${TLo!pWi<^SrmT&wlfKz^ z#q)4v_R{SAs)(9xnZDlW-KK31GoN#EbKkk-agL1h&ziO~giNmK?AUMxKZN4$3s@mL ze-z&?IG%L0>Aw7Q)}S+GsGqGv)V};dVew}}S|Xbgw$4l+S!_Ew{vf+0T)j7AXl{0O z)>j66(V7XD(|62G;X64eNLz=e-f~ZHE58s>tG*c#ac4l@w5qKR^0bBJi?1$>8i{KL z?}6J&0v6Y?ZrbBHJ2-XI)6VD0A|1+en`8BELyNw9ToU9symZ&__a*4%lip56mG$*` zP3dJ(14P3Xk6vAsCmq0f?pRZVo^YM8*=D-i?h}*Rq3Oz7!@LSbwma@U9rYs*4&|QL z{lxo%$^i zZ|!^7(NdC}y);SCQW?U3ebjS&O7;9K{?Th0KkPm`bDW=i>-_d>v_rdUJpO(qXrspg zvc(iLGQL7w?;9M)9Eb$ZhT2?h+}ZP#k(;K>({H&Mrt8RLtS-8AsKLv2b<)tL{L!V= zUtbZo?JD-ZVW;Uh_2{bbZIFIf$(cR>ZoVOntiHU2x8?Mu?RRx6DKg=6=)tDl{1Hxjs97QBsNs{;>0x8%(mt%7=qz5he(${q-3$EK&-Wf2DqK?t z*{7rypXN(94SWdCFizp{4wJ|kr9zcMd4 zpN3v~-XON!HS~C1%OXQ^?DL1OZA9}I*7{UdO+fy-|M?qk zds|Fv>s{8y&J;>ThyHxrfh7yTmP#POHVT)_<>Oj44O3|41kJ40fo(;h`1+f57#>R) zAvvKUH3DjTX$2J`6$0u)js%hD0*Po+l%yv@lY(V z2EJK9wc_%@wM9&)LRJ%FtbiIWkwSr5Jpr+4Y#IUwnaKnu)z1;~)hm?z5aG;j2yiE$ zMjMSfKAmndnP?^!O{-VY87PX<5hk6xfvEwU%II_CEU`q|<6)sdMXGgHFFyVSxhRG!o!oTmr!{P)yv12OzjU zJQyR`44B|?xN;`J#(0QNFNk@15_BaN*E=c;lmbA>SxOd{AlNX9bA4bo#>HR^XUJg& zg0fI17eyHeZiQ0d{Fz$48Uy7d)tHK)>oh8>!a_KIhEyz|GHJ+Xi8Kx~DuDx-0aByT znhc*+GEz;18Zir>3@*xMb5Jge#bGlTOwMPcFhXwty=XyYAT$=!s<9M?57GhDVwO$? z09HB3h99UWFr!v4(`w@cR7+5h#j{5(0RyGLjF=EJ5&#roviJzfXE0<84xho|b67CK z<|DoAwF*+1_`j?z;{*A26o7r(bu9Q8-j#v@OCS{1 zI006d3XjG#Dgx|}?uPv&C;y-se3WvYlFPr0)Xy6mc?FJj<}jQhK*pRO-r z;ERm=yX*f(m*eM$DMADO0hz#~Qf65!3?8#=<#T2UDIYEG{4@Kv0EvT6w8%iA4E-K_ z;%yEr*KYP8*PFy6CVNC@A8f LVBwK~Wvl-K!}|HA literal 0 HcmV?d00001 diff --git a/src/1x1_888.png b/src/1x1_888.png deleted file mode 100644 index 236976436bbfa22af0e4a64ef66c4b76de1935cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 91 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}bl&H|6fVg?3*IY3;>xU#TlB2bXm l)5S4_V`g$jM#c|*2G$m)hIO-YHvuIWJYD@<);T3K0RZHf6d?cr diff --git a/src/cached_arrow.png b/src/cached_arrow.png index 546b8d85a467b0ee009bfd18fac2a5bb8eb76170..d182e0292103418a70202d277dc296f3e7f05541 100644 GIT binary patch literal 4929 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 delta 209 zcmV;?051Q*ChP%_BYyz1Nkl^bMvznyQU&bH333L{ z<2gJ;inM8hmXbmd#L5IxD3Zkln^~0AyfDr1&NuV_37*k(V1prcD%6nR3l}kTh4+eF z$1cSLAIPzC${qT+Or!CLHD1ri^Vs@O%6X*!Mji8mDe_XzP)cBd0k)Na0$C}qBK<#d ze3tSEE!>|GyST=<7L9MDctRu2xA(p)v?u*DcpKK qQ=WbLRK(-6<(XoJ@ydm~43iCH88j#Vy9+dw!PC{xg=L*nLK6T=_A~4N diff --git a/src/gray_schrodingers_lock.png b/src/gray_schrodingers_lock.png index 1d1494946145704d2d71bec71ff589dfc4590776..f2a38a85ae5a6b9559cf6833725830abf65b2748 100644 GIT binary patch delta 153 zcmV;K0A~Nf0kr{;B!7HKL_t(|oW;_y4Z<)GK+$KE0T_ZVOL&6J07EbZOE3*ZL~EKb z0y{thl4UL=9l!I}eV+@boU@882H#v!8rOG?7;wS8ayv{o;QV%blufYZF>@X(H$oJz zrhfKwVYVR6#n3OrA8tr(45z>yquZi599$d!XLI|De)0lf%Mud|V7U*600000NkvXX Hu0mjfpM5@S delta 166 zcmdnWc!+U=NYqFh&}CJe;iVKcV$N&4fp9Z*M<*)FEerL|fW~ zwiA;dGUKuj7!3sz_6|gJ@SbhBkKQi9xy)$c^PYy9a>EJ6HARYrOL2r(3 ji*PuY#s6&fzvwjw@L~;nW$rzV00000NkvXXu0mjfofkKb delta 142 zcmbQoxQcOtN!wd{tVq_Lf|EDw!XfA^%yQ`neI;Vst0N!LV(*OVf diff --git a/src/manifest.json b/src/manifest.json index 10f1613..5231d38 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.13", + "version": "2.14", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 10f1613..5231d38 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.13", + "version": "2.14", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index d8c547b..55eceef 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.13", + "version": "2.14", "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": { diff --git a/src/options.html b/src/options.html index c2c3735..225265b 100644 --- a/src/options.html +++ b/src/options.html @@ -21,6 +21,7 @@ body { font-family: "Noto Sans", Arial, sans-serif; font-size: 10pt; + background-color: #fff; color: #444; margin: 20px; } @@ -57,6 +58,14 @@ padding: 1em; font-weight: bold; } + + /* dark mode */ + @media (prefers-color-scheme: dark) { + body { + background-color: #222; + color: #eee; + } + } diff --git a/src/popup.html b/src/popup.html index 57b619b..02efcb6 100644 --- a/src/popup.html +++ b/src/popup.html @@ -22,18 +22,20 @@ margin: 0; font-family: serif; font-size: medium; + background-color: #fff; + color: #000; } /* We want copy-paste to keep the domain in the first column, so this draws a vertical line to fake the "zeroth" column. */ table { - background-image: url("1x1_888.png"); + background-image: url("1x1_808080.png"); background-position: top 0px left 23px; background-repeat: repeat-y; } table, td, tr { border-width: 1px; border-collapse: collapse; - border-color: #888; + border-color: #808080; border-style: solid; padding: 0pt 3pt 0pt 3pt; white-space: nowrap; @@ -90,7 +92,30 @@ #spill_count_container { text-align: center; font-weight: bold; - color: #888; + color: #808080; +} + +/* dark mode */ +@media (prefers-color-scheme: dark) { + body { + background-color: #222; + color: #eee; + } + .sslImg { + filter: invert(1); + } + .addrTd { + color: #8080ff; + } + .ip4 { + color: #ff8080; + } + .ip6 { + color: #80ff80; + } + .highlight { + background-color: #505030; + } } @keyframes shake { diff --git a/src/serviceworker.png b/src/serviceworker.png index 414a0313f8956e58c80218629b3eb35eeb578b7a..11584d84f92b2fb6b073a05f9e988bb275d3beb1 100644 GIT binary patch literal 6677 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 Wf*}0ms!5Peh>i6&t0D`}$o~OgFu%(H delta 345 zcmV-f0jB4oddETBnK4OqKkw|K(k5x=!V;}~D z&0zGG_!rD3v(d&ZVIZLu2DD8`7<{BS?RlTaP{KX_uI-jh9`6Eum6Z3bCQo}KR1N~k zVu}1{l*`q7CO66q?Iz?61+9aKq0@s5XM$D%j8Od`%LJVQ5r0Rn)QH!;SgIzJW(3Uu z9L+lPPb*0kKq#)&u?ui_LM0Fu1W6%&I+{k3c|j+@U5OWJ*o{N($heZMfJfou06Z2X z%Yuvxo-4(T8rA@RF+v9EtYB|KzVG;}(KjdPnBcEv@}_plpB;YyQIN7?E#dRn@g`^r z5(IlJ(SFi5no@1YZuyxI>;2reoTB#6?gnVI^^m1#6!qVrbWe9!>qPA2&x)&U)(h?3 rWHoRTEDv Date: Mon, 18 Sep 2023 18:06:44 -0400 Subject: [PATCH 054/100] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 1d12e68..1d20786 100644 --- a/README.md +++ b/README.md @@ -12,5 +12,10 @@ https://addons.mozilla.org/firefox/addon/ipvfoo-pmarks/ 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. + ## Screenshot ![Screenshot](/misc/screenshot_webstore_1_640x400.png?raw=true) + From ae79711c0c4752bf276b74c67de8d2bc173eced4 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Thu, 5 Oct 2023 16:04:36 -0400 Subject: [PATCH 055/100] Mobile UI tweaks. Now reasonably usable on Firefox Nightly for Android. --- src/background.js | 12 +++++++++--- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- src/options.html | 1 + src/options.js | 6 +++++- src/popup.html | 14 ++++++++++++++ src/popup.js | 22 ++++++++++++++++++++-- 8 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/background.js b/src/background.js index 51d03fc..1875973 100644 --- a/src/background.js +++ b/src/background.js @@ -64,6 +64,8 @@ const NAME_VERSION = (() => { return `${m.name} v${m.version}`; })(); +const IS_MOBILE = /\bMobile\b/.test(navigator.userAgent); + let debug = false; function debugLog() { if (debug) { @@ -359,7 +361,11 @@ class TabInfo extends SaveableEntry { for (const [domain, d] of Object.entries(this.domains)) { if (domain == this.mainDomain) { pattern = d.addrVersion(); - tooltip = `${d.addr}\n${NAME_VERSION}`; + 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; @@ -1041,7 +1047,7 @@ chrome.webRequest.onErrorOccurred.addListener(forgetRequest, FILTER_ALL_URLS); // cannot vary based on content. const MENU_ID = "ipvfoo-lookup"; -chrome.contextMenus.removeAll(() => { +chrome.contextMenus?.removeAll(() => { chrome.contextMenus.create({ title: "Look up on bgp.he.net", id: MENU_ID, @@ -1051,7 +1057,7 @@ chrome.contextMenus.removeAll(() => { }); }); -chrome.contextMenus.onClicked.addListener((info, tab) => { +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)) { diff --git a/src/manifest.json b/src/manifest.json index 5231d38..d554b52 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.14", + "version": "2.15", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 5231d38..d554b52 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.14", + "version": "2.15", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 55eceef..911a564 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.14", + "version": "2.15", "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": { diff --git a/src/options.html b/src/options.html index 225265b..2c11923 100644 --- a/src/options.html +++ b/src/options.html @@ -17,6 +17,7 @@ --> + diff --git a/src/popup.js b/src/popup.js index e76ed8c..94a9464 100644 --- a/src/popup.js +++ b/src/popup.js @@ -19,6 +19,9 @@ limitations under the License. const ALL_URLS = ""; const IS_MOBILE = /\bMobile\b/.test(navigator.userAgent); +// Snip domains longer than this, to avoid horizontal scrolling. +const LONG_DOMAIN = 56; + const tabId = window.location.hash.substr(1); if (!isFinite(Number(tabId))) { throw "Bad tabId"; @@ -248,7 +251,11 @@ function makeRow(isFirst, tuple) { // Build the "Domain" column. const domainTd = document.createElement("td"); domainTd.appendChild(sslImg); - domainTd.appendChild(document.createTextNode(domain)); + if (domain.length > LONG_DOMAIN) { + domainTd.appendChild(makeSnippedText(domain, Math.floor(LONG_DOMAIN / 2))); + } else { + domainTd.appendChild(document.createTextNode(domain)); + } domainTd.className = "domainTd"; domainTd.onclick = handleClick; domainTd.oncontextmenu = handleContextMenu; @@ -297,6 +304,56 @@ function makeRow(isFirst, tuple) { return tr; } +// Given a long domain name, generate "prefix...suffix". When the user +// clicks "...", all domains are expanded. The CSS is tricky because +// we want the original domain to remain intact for clipboard purposes. +function makeSnippedText(domain, keep) { + const prefix = domain.substr(0, keep); + const snipped = domain.substr(keep, domain.length - 2 * keep); + const suffix = domain.substr(domain.length - keep); + const f = document.createDocumentFragment(); + + // Add prefix text. + f.appendChild(document.createTextNode(prefix)); + + // Add snipped text, invisible but copyable. + let snippedText = document.createElement("span"); + snippedText.className = "snippedTextInvisible"; + snippedText.textContent = snipped; + f.appendChild(snippedText); + + // Add clickable "..." image. + const snipImg = makeImg("snip.png", ""); + snipImg.className = "snipImg"; + const snipLink = document.createElement("a"); + snipLink.className = "snipLinkInvisible snipLinkVisible"; + snipLink.href = "#"; + snipLink.addEventListener("click", unsnipAll); + snipLink.appendChild(snipImg); + f.appendChild(snipLink); + + // Add suffix text. + f.appendChild(document.createTextNode(suffix)); + return f; +} + +function unsnipAll(event) { + event.preventDefault(); + removeStyles(".snippedTextInvisible", ".snipLinkVisible"); +} + +function removeStyles(...selectors) { + const stylesheet = document.styleSheets[0]; + for (const selector of selectors) { + for (let i = stylesheet.cssRules.length - 1; i >= 0; i--) { + const rule = stylesheet.cssRules[i]; + if (rule.selectorText === selector) { + stylesheet.deleteRule(i); + } + } + } +} + // Mac OS has an annoying feature where right-click selects the current // "word" (i.e. a useless fragment of the address) before showing a // context menu. Detect this by watching for the selection to change diff --git a/src/snip.png b/src/snip.png new file mode 100644 index 0000000000000000000000000000000000000000..c386be994c7cfb6697c172e1bbcb25a001d3398f GIT binary patch literal 112 zcmeAS@N?(olHy`uVBq!ia0vp^{6Ngj!3HE3xL=O}Qs$m6jv*T7lM^H+H3%LnC@A>h zzh)UTJD-7(0T3u=w{R}fc6z=zq9spzV~tt~V^U_ Date: Sun, 10 Dec 2023 18:40:02 -0500 Subject: [PATCH 071/100] Version bump to 2.18 This includes the long domain snipping feature. Also revert the pointless 120.0a1 experiment. --- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 1e0bb60..a5e685a 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.17.3", + "version": "2.18", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 1e0bb60..a5e685a 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.17.3", + "version": "2.18", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 559ce32..4354221 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.17.3", + "version": "2.18", "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": { @@ -18,7 +18,7 @@ }, "gecko_android": { "id": "ipvfoo@pmarks.net", - "strict_min_version": "120.0a1" + "strict_min_version": "120.0" } }, "page_action": { From 3ba4d357845cf3752bde679812bcbfd44190ba57 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Sun, 10 Dec 2023 19:01:22 -0500 Subject: [PATCH 072/100] Reduce LONG_DOMAIN to 50. Firefox on Linux was still showing scrollbars at 56. --- src/popup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/popup.js b/src/popup.js index 94a9464..40eaade 100644 --- a/src/popup.js +++ b/src/popup.js @@ -20,7 +20,7 @@ const ALL_URLS = ""; const IS_MOBILE = /\bMobile\b/.test(navigator.userAgent); // Snip domains longer than this, to avoid horizontal scrolling. -const LONG_DOMAIN = 56; +const LONG_DOMAIN = 50; const tabId = window.location.hash.substr(1); if (!isFinite(Number(tabId))) { From 65b5f7d45fe9565b3d5c54b945ff612fb92e3309 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Thu, 11 Jan 2024 15:45:41 -0500 Subject: [PATCH 073/100] Update README.md --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1263263..77e9dd4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ Everything is captured privately using the webRequest API, without creating any additional network traffic. +## Screenshot +![Screenshot](/misc/screenshot_webstore_1_640x400.png?raw=true) + ## Add to Chrome https://chrome.google.com/webstore/detail/ipvfoo/ecanpcehffngcegjmadlcijfolapggal @@ -23,7 +26,3 @@ https://microsoftedge.microsoft.com/addons/detail/ipvfoo/dphnkggpaicipkljebciobe ## 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. - -## Screenshot -![Screenshot](/misc/screenshot_webstore_1_640x400.png?raw=true) - From dd6c962472b6f1e4f4bb1482e2432b56611b6a90 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Wed, 19 Jun 2024 12:39:47 -0400 Subject: [PATCH 074/100] Disable broken CWS badges --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 77e9dd4..2fbd619 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ Everything is captured privately using the webRequest API, without creating any ## Add to Chrome https://chrome.google.com/webstore/detail/ipvfoo/ecanpcehffngcegjmadlcijfolapggal + ## Add to Firefox https://addons.mozilla.org/addon/ipvfoo/ From 778075183be21acf6314dc1fcc94e18df687c275 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Tue, 4 Feb 2025 17:25:49 -0500 Subject: [PATCH 075/100] Merge the nat64 branch (#61) * NAT64 detection experiment Currently this just console.log()s when the user visits specific IPv4-only websites. * Integrate NAT64 detection into the options UI and background script. --- src/background.js | 71 ++++++++-- src/common.js | 65 ++++++++- src/iputil.js | 204 +++++++++++++++++++++++++++++ src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 4 +- src/options.html | 31 ++++- src/options.js | 29 +++- src/popup.html | 1 + src/popup.js | 7 - tests/iputil_test.html | 117 +++++++++++++++++ tests/tinytest.js | 91 +++++++++++++ 12 files changed, 596 insertions(+), 28 deletions(-) create mode 100644 src/iputil.js create mode 100644 tests/iputil_test.html create mode 100644 tests/tinytest.js diff --git a/src/background.js b/src/background.js index 56ac370..c65a746 100644 --- a/src/background.js +++ b/src/background.js @@ -38,8 +38,9 @@ user can demand a popup before any IP addresses are available. "use strict"; if (chrome.runtime.getManifest().background.service_worker) { - // This line runs on Chrome, but not Firefox. - importScripts("common.js"); + // This only runs on Chrome. + // Firefox uses manifest.json/background/scripts instead. + importScripts("iputil.js", "common.js"); } // Possible states for an instance of TabInfo. @@ -100,6 +101,39 @@ function parseUrl(url) { return { domain: domain, ssl: ssl, ws: ws, origin: u.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. + addNAT64(packed.slice(0, 96/4)); +} + +function reformatForNAT64(addr) { + let packed128 = ""; + try { + packed128 = parseIP(addr); + } catch { + return addr; // no change + } + if (packed128.length != 128/4) { + return addr; // no change + } + const isNAT64 = options[NAT64_KEY].has(packed128.slice(0, 96/4)); + return formatIPv6(packed128, /*with_dots=*/isNAT64); +} + class SaveableEntry { #prefix; #id; @@ -478,11 +512,9 @@ class DomainInfo { return new DomainInfo(tabInfo, domain, addr, flags); } - // 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. addrVersion() { if (this.addr) { - if (/^64:ff9b::/.test(this.addr)) return "4"; // RFC6052 + // NAT64 addresses use the prefix::a.b.c.d format. if (this.addr.indexOf(".") >= 0) return "4"; if (this.addr.indexOf(":") >= 0) return "6"; } @@ -548,7 +580,7 @@ class IPCacheEntry extends SaveableEntry { // tabId -> TabInfo const tabMap = new SaveableMap(TabInfo, "tab/") -// requestId -> {tabInfo, domain} +// requestId -> RequestInfo const requestMap = new SaveableMap(RequestInfo, "req/"); // Firefox-only domain->ip cache, to help work around @@ -621,6 +653,7 @@ const initStorage = async () => { // 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 = []; @@ -954,6 +987,11 @@ chrome.webRequest.onResponseStarted.addListener(async (details) => { let addr = details.ip; let fromCache = details.fromCache; + + if (!fromCache) { + updateNAT64(parsed.domain, addr); + } + if (ipCache) { // This runs on Firefox only. if (addr) { @@ -973,7 +1011,7 @@ chrome.webRequest.onResponseStarted.addListener(async (details) => { } } } - addr = addr || "(no address)"; + addr = reformatForNAT64(addr) || "(no address)"; let flags = parsed.ssl ? FLAG_SSL : FLAG_NOSSL; if (parsed.ws) { @@ -1048,12 +1086,21 @@ chrome.contextMenus?.onClicked.addListener((info, tab) => { watchOptions(async (optionsChanged) => { await storageReady; - for (const option of optionsChanged) { - if (!option.endsWith("ColorScheme")) continue; - for (const tabInfo of Object.values(tabMap)) { - if (tabInfo.color == option) { - tabInfo.refreshPageAction(); + 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; + } } } + if (refreshPageAction) { + tabInfo.refreshPageAction(); + } } }); diff --git a/src/common.js b/src/common.js index a4e6393..2403dec 100644 --- a/src/common.js +++ b/src/common.js @@ -16,6 +16,8 @@ limitations under the License. "use strict"; +// Requires @@ -119,6 +139,15 @@

Icon color scheme


Note that popups use the system light/dark theme. +

+ +

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 cc384fd..ea8787a 100644 --- a/src/options.js +++ b/src/options.js @@ -32,11 +32,33 @@ window.onload = async () => { } } + const ipv4pages = document.getElementById("ipv4pages"); + for (const domain of IPV4_ONLY_DOMAINS.keys()) { + const li = document.createElement("li"); + const a = document.createElement("a"); + a.href = `https://${domain}`; + a.target = "_blank"; + a.textContent = domain; + li.appendChild(a); + ipv4pages.appendChild(li); + } + watchOptions(function(optionsChanged) { for (const option of optionsChanged) { - if (!option.endsWith("ColorScheme")) continue; - const radio = document.optionsForm[option]; - radio.value = options[option]; + if (option.endsWith("ColorScheme")) { + const radio = document.optionsForm[option]; + radio.value = options[option]; + } else if (option == NAT64_KEY) { + const table = document.getElementById("nat64"); + removeChildren(table); + for (const packed96 of Array.from(options[NAT64_KEY]).sort()) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.appendChild(document.createTextNode(formatIPv6(packed96) + "/96")); + tr.appendChild(td); + table.appendChild(tr); + } + } } disableAll(false); }); @@ -53,6 +75,7 @@ window.onload = async () => { }; document.getElementById("revert_btn").onclick = function() { + revertNAT64(); if (setOptions(DEFAULT_OPTIONS)) { disableAll(true); } diff --git a/src/popup.html b/src/popup.html index 6735bb6..8fc2120 100644 --- a/src/popup.html +++ b/src/popup.html @@ -183,6 +183,7 @@ } + diff --git a/src/popup.js b/src/popup.js index 40eaade..cd7aa09 100644 --- a/src/popup.js +++ b/src/popup.js @@ -183,13 +183,6 @@ function scrollbarHack() { }, 200); } -function removeChildren(n) { - while (n.hasChildNodes()) { - n.removeChild(n.lastChild); - } - return n; -} - // Copy the contents of src into dst, making minimal changes. function minimalCopy(src, dst) { dst.className = src.className; diff --git a/tests/iputil_test.html b/tests/iputil_test.html new file mode 100644 index 0000000..dc881b5 --- /dev/null +++ b/tests/iputil_test.html @@ -0,0 +1,117 @@ + + + + + + +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; From 3efb57185e672fe7d5884ac15d98b9ec806a76d6 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Wed, 5 Feb 2025 12:30:17 -0500 Subject: [PATCH 076/100] NAT64 tweaks - Let users call addNAT64("f00::") from the console. - Don't send dotted IPv6 addresses to bgp.he.net. Relates to https://github.com/pmarks-net/ipvfoo/issues/60 --- src/background.js | 9 +++++---- src/common.js | 16 +++++++++++++--- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 2 +- src/popup.js | 2 +- 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/background.js b/src/background.js index c65a746..4ade534 100644 --- a/src/background.js +++ b/src/background.js @@ -117,10 +117,10 @@ function updateNAT64(domain, addr) { return; } // If this is a new prefix, the watchOptions callback will handle it. - addNAT64(packed.slice(0, 96/4)); + addPackedNAT64(packed.slice(0, 96/4)); } -function reformatForNAT64(addr) { +function reformatForNAT64(addr, doLookup=true) { let packed128 = ""; try { packed128 = parseIP(addr); @@ -130,7 +130,7 @@ function reformatForNAT64(addr) { if (packed128.length != 128/4) { return addr; // no change } - const isNAT64 = options[NAT64_KEY].has(packed128.slice(0, 96/4)); + const isNAT64 = doLookup && options[NAT64_KEY].has(packed128.slice(0, 96/4)); return formatIPv6(packed128, /*with_dots=*/isNAT64); } @@ -1072,7 +1072,8 @@ 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)) { - chrome.tabs.create({url: `https://bgp.he.net/ip/${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 { diff --git a/src/common.js b/src/common.js index 2403dec..68f84ef 100644 --- a/src/common.js +++ b/src/common.js @@ -239,7 +239,17 @@ function setOptions(newOptions) { return true; // caller should wait for watchOptions() } -function addNAT64(packed96) { +// Users can manually call this function to add a NAT64 prefix from the console. +function addNAT64(ip) { + if (ip.endsWith("/96")) { + ip = ip.slice(0, ip.length-3); + } + const packed96 = parseIP(ip).slice(0, 96/4); + addPackedNAT64(packed96); + return `Added NAT64 prefix ${formatIPv6(packed96)}/96`; +} + +function addPackedNAT64(packed96) { if (options[NAT64_KEY].has(packed96)) { return; } @@ -249,7 +259,7 @@ function addNAT64(packed96) { chrome.storage.sync.set({[key]: 1}); // NAT64 changes are reported synchronously. When onChanged fires, // our local Set is used for deduplication. - _watchOptionsFunc([NAT64_KEY]); + _watchOptionsFunc?.([NAT64_KEY]); } function revertNAT64() { @@ -264,6 +274,6 @@ function revertNAT64() { chrome.storage.sync.remove(toRemove); // NAT64 changes are reported synchronously. When onChanged fires, // our local Set is used for deduplication. - _watchOptionsFunc([NAT64_KEY]); + _watchOptionsFunc?.([NAT64_KEY]); } } \ No newline at end of file diff --git a/src/manifest.json b/src/manifest.json index 60f766e..ed5c2a5 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.19", + "version": "2.20", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 60f766e..ed5c2a5 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.19", + "version": "2.20", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 5165777..407aa2d 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.19", + "version": "2.20", "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": { diff --git a/src/popup.js b/src/popup.js index cd7aa09..04b90e7 100644 --- a/src/popup.js +++ b/src/popup.js @@ -62,7 +62,7 @@ function connectToExtension() { const port = chrome.runtime.connect(null, {name: tabId}); port.onMessage.addListener((msg) => { document.bgColor = ""; - console.log("onMessage", msg.cmd, msg); + //console.log("onMessage", msg.cmd, msg); switch (msg.cmd) { case "pushAll": return pushAll(msg.tuples, msg.pattern, msg.spillCount); From c383748ebfd14b355dfc276a06213bffd6fa2bab Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Thu, 6 Feb 2025 18:31:00 -0500 Subject: [PATCH 077/100] Add light/dark autodetection. I'm tired of waiting for https://github.com/w3c/webextensions/issues/229 --- src/background.js | 72 ++++++++++++++++++++++++++++++- src/common.js | 13 +++++- src/detectdarkmode.html | 6 +++ src/detectdarkmode.js | 3 ++ src/iputil.js | 2 + src/manifest.json | 3 +- src/manifest/chrome-manifest.json | 3 +- src/options.html | 8 ++++ 8 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 src/detectdarkmode.html create mode 100644 src/detectdarkmode.js diff --git a/src/background.js b/src/background.js index 4ade534..1deacf5 100644 --- a/src/background.js +++ b/src/background.js @@ -274,6 +274,7 @@ class TabInfo extends SaveableEntry { if (!spriteImg.ready) throw "must await spriteImgReady!"; if (!options.ready) throw "must await optionsReady!"; + if (!darkMode.ready) throw "must await darkModeReady!"; if (tabMap[tabId]) throw "Duplicate entry in tabMap"; if (tabTracker.exists(tabId)) { @@ -426,7 +427,10 @@ class TabInfo extends SaveableEntry { // Don't waste time redrawing the same icon. if (this.lastPattern != pattern) { - const color = options[this.color]; + let color = options[this.color]; + if (color == "auto") { + color = darkMode.value ? "lightfg" : "darkfg"; + } action.setIcon({ "tabId": this.id(), "imageData": { @@ -634,11 +638,77 @@ function lookupOriginMap(origin) { return originMap[origin] || new Set(); } +// This horrific mess can eventually be replaced by +// https://github.com/w3c/webextensions/issues/229 +let initDarkMode = null; +const darkMode = {ready: false, resolve: null, value: false}; +if (typeof window !== 'undefined' && window.matchMedia) { + // Watching for Dark Mode is trivial in Firefox. + initDarkMode = (async () => { + const query = window.matchMedia('(prefers-color-scheme: dark)'); + darkMode.value = query.matches; + query.addEventListener("change", (event) => { + changeDarkMode(event.matches); + }); + darkMode.ready = true; + }); +} else { + initDarkMode = (async () => { + const p = new Promise((resolve) => {darkMode.resolve = resolve}); + try { + await chrome.offscreen.createDocument({ + url: "detectdarkmode.html", + reasons: ['MATCH_MEDIA'], + justification: 'detect light/dark mode for icon colors', + }); + darkMode.value = await p; + } catch { + console.log("detectdarkmode failed!"); + } + // The offscreen document can't provide darkMode updates, so kill it now. + // We will get updates from the popup/option windows instead. + try { + await chrome.offscreen.closeDocument(); + } catch { + // ignore + } + darkMode.ready = true; + }); + + chrome.runtime.onMessage.addListener((message) => { + console.log("onMessage", message); + if (message.hasOwnProperty("darkModeOffscreen")) { + darkMode.resolve?.(message.darkModeOffscreen); + darkMode.resolve = null; + } else if (message.hasOwnProperty("darkModeInteractive")) { + // Receive updates from popup/option windows. + changeDarkMode(message.darkModeInteractive); + } + }); +} +const darkModeReady = initDarkMode(); + +async function changeDarkMode(newValue) { + if (!darkMode.ready || darkMode.value == newValue) return; + darkMode.value = newValue; + + await storageReady; + const RCS = "regularColorScheme"; + if (options[RCS] == "auto") { + for (const tabInfo of Object.values(tabMap)) { + if (tabInfo.color == RCS){ + tabInfo.refreshPageAction(); + } + } + } +} + // Must "await storageReady;" before reading maps. // You can force initStorage() from the console for debugging purposes. const initStorage = async () => { await spriteImgReady; await optionsReady; + await darkModeReady; // Migrate previous-version data from local to session storage. const oldItems = await chrome.storage.local.get(); diff --git a/src/common.js b/src/common.js index 68f84ef..24fb7e9 100644 --- a/src/common.js +++ b/src/common.js @@ -159,7 +159,7 @@ function drawSprite(ctx, size, targets, sources) { } const DEFAULT_OPTIONS = { - regularColorScheme: "darkfg", + regularColorScheme: "auto", incognitoColorScheme: "lightfg", }; @@ -276,4 +276,15 @@ function revertNAT64() { // our local Set is used for deduplication. _watchOptionsFunc?.([NAT64_KEY]); } +} + +if (chrome.runtime.getManifest().background.service_worker && + typeof window !== 'undefined' && window.matchMedia) { + // We are running in Chrome, and the Options or Popup UI is visible. + // Send darkMode updates to the service_worker. + const query = window.matchMedia('(prefers-color-scheme: dark)'); + chrome.runtime.sendMessage({darkModeInteractive: query.matches}); + query.addEventListener("change", (event) => { + chrome.runtime.sendMessage({darkModeInteractive: event.matches}); + }); } \ No newline at end of file diff --git a/src/detectdarkmode.html b/src/detectdarkmode.html new file mode 100644 index 0000000..8a122f9 --- /dev/null +++ b/src/detectdarkmode.html @@ -0,0 +1,6 @@ + + + + + + \ 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/iputil.js b/src/iputil.js index d4fc574..7225b07 100644 --- a/src/iputil.js +++ b/src/iputil.js @@ -1,3 +1,5 @@ +"use strict"; + const DOT = '.'; const COLON = ':'; const IPV6_PART_COUNT = 8; diff --git a/src/manifest.json b/src/manifest.json index ed5c2a5..6bacd36 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -23,7 +23,8 @@ "contextMenus", "storage", "webNavigation", - "webRequest" + "webRequest", + "offscreen" ], "host_permissions": [ "" diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index ed5c2a5..6bacd36 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -23,7 +23,8 @@ "contextMenus", "storage", "webNavigation", - "webRequest" + "webRequest", + "offscreen" ], "host_permissions": [ "" diff --git a/src/options.html b/src/options.html index 38ce64e..7013416 100644 --- a/src/options.html +++ b/src/options.html @@ -36,6 +36,8 @@ border-color: #888; border-style: solid; padding: 0; + white-space: nowrap; + vertical-align: middle; } #nat64 table, #nat64 td, #nat64 tr { font-family: monospace; @@ -114,6 +116,12 @@

    Icon color scheme

    + + + Incognito tabs: From 8afad871316c5e5618488c136b703830e7585b85 Mon Sep 17 00:00:00 2001 From: Paul Marks Date: Fri, 7 Feb 2025 14:32:27 -0500 Subject: [PATCH 078/100] Options cleanups: - Make the color scheme selector fit on mobile without scrolling. - Replace the 'disabler' with a dirty counter. --- src/common.js | 93 ++++++++++++++++++++---------- src/manifest.json | 2 +- src/manifest/chrome-manifest.json | 2 +- src/manifest/firefox-manifest.json | 3 +- src/options.html | 28 ++++----- src/options.js | 19 +----- 6 files changed, 82 insertions(+), 65 deletions(-) diff --git a/src/common.js b/src/common.js index 24fb7e9..9f7f636 100644 --- a/src/common.js +++ b/src/common.js @@ -169,50 +169,55 @@ const NAT64_DEFAULT = parseIP("64:ff9b::").slice(0, 96/4); let _watchOptionsFunc = null; const options = {ready: false, [NAT64_KEY]: new Set([NAT64_DEFAULT])}; +const optionsDirty = {}; // {option: number of writes in flight} const optionsReady = (async function() { - const items = await chrome.storage.sync.get(); - for (const option of Object.keys(DEFAULT_OPTIONS)) { - options[option] = items.hasOwnProperty(option) ? - items[option] : DEFAULT_OPTIONS[option]; + for (const [option, value] of Object.entries(DEFAULT_OPTIONS)) { + options[option] = value; + optionsDirty[option] = 0; } - for (const option of Object.keys(items)) { - if (NAT64_VALIDATE.test(option)) { + const items = await chrome.storage.sync.get(); + for (const [option, value] of Object.entries(items)) { + if (DEFAULT_OPTIONS.hasOwnProperty(option)) { + options[option] = value; + } else if (NAT64_VALIDATE.test(option)) { options[NAT64_KEY].add(option.slice(NAT64_KEY.length)); } } options.ready = true; - if (_watchOptionsFunc) { - _watchOptionsFunc(Object.keys(options)); - } + _watchOptionsFunc?.(Object.keys(options)); })(); chrome.storage.sync.onChanged.addListener(function(changes) { // changes = {option: {oldValue: x, newValue: y}} if (!options.ready) return; const optionsChanged = []; - for (const option of Object.keys(DEFAULT_OPTIONS)) { - const change = changes[option]; - if (!change) continue; - options[option] = change.hasOwnProperty("newValue") ? - change.newValue : DEFAULT_OPTIONS[option]; - optionsChanged.push(option); - } - let nat64Changed = false; for (const [option, {oldValue, newValue}] of Object.entries(changes)) { - if (NAT64_VALIDATE.test(option)) { + if (DEFAULT_OPTIONS.hasOwnProperty(option)) { + const value = newValue || DEFAULT_OPTIONS[option]; + if (options[option] != value) { + if (optionsDirty[option] > 1) { + // Forget local changes that occurred mid-write. + optionsDirty[option] = 1; + } + options[option] = value; + optionsChanged.push(option); + } + } else if (NAT64_VALIDATE.test(option)) { const packed96 = option.slice(NAT64_KEY.length); if (newValue && !options[NAT64_KEY].has(packed96)) { options[NAT64_KEY].add(packed96); - nat64Changed = true; } else if (!newValue && options[NAT64_KEY].has(packed96)) { options[NAT64_KEY].delete(packed96); - nat64Changed = true; + } else { + continue; // no change + } + if (!optionsChanged.includes(NAT64_KEY)) { + optionsChanged.push(NAT64_KEY); } } } - if (nat64Changed) optionsChanged.push(NAT64_KEY); - if (_watchOptionsFunc && optionsChanged.length) { - _watchOptionsFunc(optionsChanged); + if (optionsChanged.length) { + _watchOptionsFunc?.(optionsChanged); } }); @@ -225,18 +230,44 @@ function watchOptions(f) { } function setOptions(newOptions) { - console.log("setOptions", newOptions); const toSet = {}; + const optionsChanged = []; for (const option of Object.keys(DEFAULT_OPTIONS)) { - if (newOptions[option] != options[option]) { - toSet[option] = newOptions[option]; + const value = newOptions[option]; + if (options[option] != value) { + options[option] = value; + optionsChanged.push(option); + if (++optionsDirty[option] == 1) { + toSet[option] = value; + } else { + // dirty > 1; the value is buffered and written later. + console.log("setOptions buffered", option); + } } } - if (Object.keys(toSet).length == 0) { - return false; // no change + const doSet = () => { + if (Object.keys(toSet).length == 0) { + return; // no change + } + chrome.storage.sync.set(toSet, () => { + for (const [option, value] of Object.entries(toSet)) { + const dirty = optionsDirty[option]; + if (dirty > 1 && value != options[option]) { + // user changed the value mid-write; push the latest value. + toSet[option] = options[option]; + optionsDirty[option] = 1; + } else { + delete toSet[option]; + optionsDirty[option] = 0; + } + } + doSet(); + }); + }; + doSet(); + if (optionsChanged.length) { + _watchOptionsFunc?.(optionsChanged); } - chrome.storage.sync.set(toSet); - return true; // caller should wait for watchOptions() } // Users can manually call this function to add a NAT64 prefix from the console. @@ -287,4 +318,4 @@ if (chrome.runtime.getManifest().background.service_worker && query.addEventListener("change", (event) => { chrome.runtime.sendMessage({darkModeInteractive: event.matches}); }); -} \ No newline at end of file +} diff --git a/src/manifest.json b/src/manifest.json index 6bacd36..07e1d2f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.20", + "version": "2.21", "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": { diff --git a/src/manifest/chrome-manifest.json b/src/manifest/chrome-manifest.json index 6bacd36..07e1d2f 100644 --- a/src/manifest/chrome-manifest.json +++ b/src/manifest/chrome-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.20", + "version": "2.21", "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": { diff --git a/src/manifest/firefox-manifest.json b/src/manifest/firefox-manifest.json index 407aa2d..6edfabb 100644 --- a/src/manifest/firefox-manifest.json +++ b/src/manifest/firefox-manifest.json @@ -1,7 +1,7 @@ { "name": "IPvFoo", "manifest_version": 3, - "version": "2.20", + "version": "2.21", "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": { @@ -17,7 +17,6 @@ "strict_min_version": "115.0" }, "gecko_android": { - "id": "ipvfoo@pmarks.net", "strict_min_version": "120.0" } }, diff --git a/src/options.html b/src/options.html index 7013416..646bf79 100644 --- a/src/options.html +++ b/src/options.html @@ -17,7 +17,7 @@ --> - +