diff --git a/background_scripts/main.js b/background_scripts/main.js index 7d37e6b1b..d0988172b 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -597,6 +597,69 @@ const HintCoordinator = { }, }; +let globallyDisabled = false; +const globallyDisabledLoaded = chrome.storage.local.get("globallyDisabled").then((items) => { + if (items.globallyDisabled != null) globallyDisabled = items.globallyDisabled; +}); + +function getIconSet() { + if (bgUtils.isFirefox()) { + return { + "enabled": "../icons/action_enabled.svg", + "partial": "../icons/action_partial.svg", + "disabled": "../icons/action_disabled.svg", + }; + } + return { + "enabled": { + "16": "../icons/action_enabled_16.png", + "32": "../icons/action_enabled_32.png", + }, + "partial": { + "16": "../icons/action_partial_16.png", + "32": "../icons/action_partial_32.png", + }, + "disabled": { + "16": "../icons/action_disabled_16.png", + "32": "../icons/action_disabled_32.png", + }, + }; +} + +async function toggleGloballyDisabled() { + globallyDisabled = !globallyDisabled; + chrome.storage.local.set({ globallyDisabled }); + const iconSet = getIconSet(); + const whichIcon = globallyDisabled ? "disabled" : "enabled"; + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + chrome.action.setIcon({ path: iconSet[whichIcon], tabId: tab.id }).catch(() => {}); + chrome.tabs.sendMessage(tab.id, { + handler: "toggleGloballyDisabled", + disabled: globallyDisabled, + }).catch(() => {}); + } +} + +chrome.commands.onCommand.addListener(async (command) => { + if (command === "toggleGloballyDisabled") { + await toggleGloballyDisabled(); + } +}); + +chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request.handler === "getGloballyDisabled") { + chrome.storage.local.get("globallyDisabled", (items) => { + sendResponse({ globallyDisabled: items.globallyDisabled ?? false }); + }); + return true; + } else if (request.handler === "toggleGloballyDisabled") { + toggleGloballyDisabled().then(() => sendResponse()); + return true; + } + return false; +}); + const sendRequestHandlers = { runBackgroundCommand(request, sender) { return BackgroundCommands[request.registryEntry.command](request, sender); @@ -661,8 +724,14 @@ const sendRequestHandlers = { async initializeFrame(request, sender) { // Check whether the extension is enabled for the top frame's URL, rather than the URL of the // specific frame that sent this request. + await globallyDisabledLoaded; const enabledState = exclusions.isEnabledForUrl(sender.tab.url); + if (globallyDisabled) { + enabledState.isEnabledForUrl = false; + enabledState.passKeys = ""; + } + const isTopFrame = sender.frameId == 0; if (isTopFrame) { let whichIcon; @@ -674,30 +743,7 @@ const sendRequestHandlers = { whichIcon = "enabled"; } - let iconSet = { - "enabled": { - "16": "../icons/action_enabled_16.png", - "32": "../icons/action_enabled_32.png", - }, - "partial": { - "16": "../icons/action_partial_16.png", - "32": "../icons/action_partial_32.png", - }, - "disabled": { - "16": "../icons/action_disabled_16.png", - "32": "../icons/action_disabled_32.png", - }, - }; - - if (bgUtils.isFirefox()) { - // Only Firefox supports SVG icons. - iconSet = { - "enabled": "../icons/action_enabled.svg", - "partial": "../icons/action_partial.svg", - "disabled": "../icons/action_disabled.svg", - }; - } - + const iconSet = getIconSet(); chrome.action.setIcon({ path: iconSet[whichIcon], tabId: sender.tab.id }); } @@ -926,6 +972,17 @@ Object.assign(globalThis, { BackgroundCommands, majorVersionHasIncreased, nextZoomLevel, + toggleGloballyDisabled, + sendRequestHandlers, +}); + +Object.defineProperty(globalThis, "globallyDisabled", { + get() { + return globallyDisabled; + }, + set(v) { + globallyDisabled = v; + }, }); // The chrome.runtime.onStartup and onInstalled events are not fired when disabling and then diff --git a/content_scripts/vimium_frontend.js b/content_scripts/vimium_frontend.js index c4d5331ec..36e1ded8f 100644 --- a/content_scripts/vimium_frontend.js +++ b/content_scripts/vimium_frontend.js @@ -352,6 +352,15 @@ globalThis.lastFocusedInput = (function () { })(); const messageHandlers = { + toggleGloballyDisabled(request) { + if (request.disabled) { + isEnabledForUrl = false; + HUD.show("Vimium disabled", 2000); + } else { + checkIfEnabledForUrl(); + HUD.show("Vimium enabled", 2000); + } + }, getFocusStatus(_request, _sender) { return { focused: windowIsFocused(), @@ -401,7 +410,9 @@ async function handleMessage(request, sender) { // Some request are handled elsewhere in the code base; ignore them here. const shouldHandleMessage = request.handler !== "userIsInteractingWithThePage" && (isEnabledForUrl || - ["checkEnabledAfterURLChange", "runInTopFrame"].includes(request.handler)); + ["checkEnabledAfterURLChange", "runInTopFrame", "toggleGloballyDisabled"].includes( + request.handler, + )); if (shouldHandleMessage) { const result = await messageHandlers[request.handler](request, sender); return result; diff --git a/manifest.json b/manifest.json index 783852cbb..6c383ad8a 100644 --- a/manifest.json +++ b/manifest.json @@ -94,6 +94,11 @@ // "strict_min_version": "109.0" // } // }, + "commands": { + "toggleGloballyDisabled": { + "description": "Toggle Vimium on/off" + } + }, "action": { "default_icon": { "16": "icons/action_disabled_16.png", diff --git a/pages/action.css b/pages/action.css index 145c3bb1d..370a93955 100644 --- a/pages/action.css +++ b/pages/action.css @@ -26,15 +26,21 @@ h1 { padding: var(--padding); } -#dialog-body > * { +#globally-disabled-notice { + padding: var(--padding); + padding-right: 0; + flex-grow: 1; +} + +#dialog-body > *, #globally-disabled-notice > * { margin: 10px 0; } -#dialog-body > *:first-child { +#dialog-body > *:first-child, #globally-disabled-notice > *:first-child { margin-top: 0; } -#dialog-body > *:last-child { +#dialog-body > *:last-child, #globally-disabled-notice > *:last-child { margin-bottom: 0; } diff --git a/pages/action.html b/pages/action.html index 2e1fae6cf..c15cf6a42 100644 --- a/pages/action.html +++ b/pages/action.html @@ -46,6 +46,11 @@

+ +
All Vimium keys are enabled on this page. diff --git a/pages/action.js b/pages/action.js index faf4975f3..055d49335 100644 --- a/pages/action.js +++ b/pages/action.js @@ -45,6 +45,19 @@ const ActionPage = { return; } + const { globallyDisabled } = await chrome.runtime.sendMessage({ + handler: "getGloballyDisabled", + }); + if (globallyDisabled) { + hideUI(); + document.querySelector("#globally-disabled-notice").style.display = "block"; + document.querySelector("#re-enable-vimium").addEventListener("click", async () => { + await chrome.runtime.sendMessage({ handler: "toggleGloballyDisabled" }); + globalThis.close(); + }); + return; + } + document.querySelector("#optionsLink").href = chrome.runtime.getURL("pages/options.html"); const saveButton = document.querySelector("#save"); diff --git a/tests/unit_tests/global_toggle_test.js b/tests/unit_tests/global_toggle_test.js new file mode 100644 index 000000000..e0dd03bb5 --- /dev/null +++ b/tests/unit_tests/global_toggle_test.js @@ -0,0 +1,127 @@ +import "./test_helper.js"; +import "../../lib/settings.js"; +import "../../background_scripts/main.js"; + +context("Global toggle", () => { + setup(async () => { + await Settings.onLoaded(); + globallyDisabled = false; + await chrome.storage.local.clear(); + }); + + teardown(async () => { + globallyDisabled = false; + await Settings.clear(); + await chrome.storage.local.clear(); + }); + + should("toggle globallyDisabled state", async () => { + assert.isFalse(globallyDisabled); + stub(chrome.tabs, "query", () => []); + await toggleGloballyDisabled(); + assert.isTrue(globallyDisabled); + await toggleGloballyDisabled(); + assert.isFalse(globallyDisabled); + }); + + should("persist state to chrome.storage.local", async () => { + stub(chrome.tabs, "query", () => []); + await toggleGloballyDisabled(); + const stored = await chrome.storage.local.get("globallyDisabled"); + assert.isTrue(stored.globallyDisabled); + }); + + should("update icon on all tabs when toggled", async () => { + const iconUpdates = []; + stub(chrome.tabs, "query", () => [{ id: 1 }, { id: 2 }]); + stub(chrome.tabs, "sendMessage", () => Promise.resolve()); + stub(chrome.action, "setIcon", (args) => { + iconUpdates.push(args); + return Promise.resolve(); + }); + await toggleGloballyDisabled(); + assert.equal(2, iconUpdates.length); + assert.equal(1, iconUpdates[0].tabId); + assert.equal(2, iconUpdates[1].tabId); + }); + + should("send toggleGloballyDisabled message to all tabs", async () => { + const sentMessages = []; + stub(chrome.tabs, "query", () => [{ id: 1 }, { id: 2 }]); + stub(chrome.tabs, "sendMessage", (tabId, message) => { + sentMessages.push({ tabId, message }); + return Promise.resolve(); + }); + stub(chrome.action, "setIcon", () => Promise.resolve()); + await toggleGloballyDisabled(); + assert.equal(2, sentMessages.length); + assert.equal("toggleGloballyDisabled", sentMessages[0].message.handler); + assert.isTrue(sentMessages[0].message.disabled); + assert.equal(1, sentMessages[0].tabId); + assert.equal(2, sentMessages[1].tabId); + }); +}); + +context("initializeFrame with global toggle", () => { + setup(async () => { + await Settings.onLoaded(); + globallyDisabled = false; + await chrome.storage.local.clear(); + }); + + teardown(async () => { + globallyDisabled = false; + await Settings.clear(); + await chrome.storage.local.clear(); + }); + + should("return isEnabledForUrl false when globally disabled", async () => { + globallyDisabled = true; + stub(chrome.action, "setIcon", () => Promise.resolve()); + const sender = { tab: { url: "http://www.example.com/", id: 1 }, frameId: 0 }; + const response = await sendRequestHandlers.initializeFrame({}, sender); + assert.isFalse(response.isEnabledForUrl); + assert.equal("", response.passKeys); + }); + + should("return isEnabledForUrl true when globally enabled", async () => { + globallyDisabled = false; + stub(chrome.action, "setIcon", () => Promise.resolve()); + const sender = { tab: { url: "http://www.example.com/", id: 1 }, frameId: 0 }; + const response = await sendRequestHandlers.initializeFrame({}, sender); + assert.isTrue(response.isEnabledForUrl); + }); + + should("set disabled icon when globally disabled", async () => { + globallyDisabled = true; + let iconPath = null; + stub(chrome.action, "setIcon", (args) => { + iconPath = args.path; + return Promise.resolve(); + }); + const sender = { tab: { url: "http://www.example.com/", id: 1 }, frameId: 0 }; + await sendRequestHandlers.initializeFrame({}, sender); + assert.isTrue(iconPath["16"].includes("disabled")); + }); + + should("respect URL exclusion rules when globally enabled", async () => { + globallyDisabled = false; + await Settings.set("exclusionRules", [{ pattern: "http*://mail.google.com/*", passKeys: "" }]); + stub(chrome.action, "setIcon", () => Promise.resolve()); + const sender = { + tab: { url: "http://mail.google.com/inbox", id: 1 }, + frameId: 0, + }; + const response = await sendRequestHandlers.initializeFrame({}, sender); + assert.isFalse(response.isEnabledForUrl); + }); + + should("override URL exclusion when globally disabled", async () => { + globallyDisabled = true; + await Settings.set("exclusionRules", []); + stub(chrome.action, "setIcon", () => Promise.resolve()); + const sender = { tab: { url: "http://www.example.com/", id: 1 }, frameId: 0 }; + const response = await sendRequestHandlers.initializeFrame({}, sender); + assert.isFalse(response.isEnabledForUrl); + }); +}); diff --git a/tests/unit_tests/test_chrome_stubs.js b/tests/unit_tests/test_chrome_stubs.js index d5329d0c9..db7190aa3 100644 --- a/tests/unit_tests/test_chrome_stubs.js +++ b/tests/unit_tests/test_chrome_stubs.js @@ -192,6 +192,18 @@ globalThis.chrome = { setBadgeBackgroundColor() {}, }, + commands: { + onCommand: { + addListener() {}, + }, + }, + + action: { + setIcon() { + return Promise.resolve(); + }, + }, + sessions: { MAX_SESSION_RESULTS: 25, },