diff --git a/scripts/esbuild.js b/scripts/esbuild.js index 4448d427..0e2d31d0 100644 --- a/scripts/esbuild.js +++ b/scripts/esbuild.js @@ -1,6 +1,6 @@ const esbuild = require('esbuild') -for (let f of ["content", "background", "help", "newtab", "reader", "commandline_frame", "qrCodeGenerator"]) { +for (let f of ["content", "background", "help", "newtab", "reader", "commandline_frame", "qrCodeGenerator", "browser_action_popup"]) { esbuild.build({ entryPoints: [`src/${f}.ts`], bundle: true, diff --git a/src/background.ts b/src/background.ts index ce5fb0f6..e77edc4b 100644 --- a/src/background.ts +++ b/src/background.ts @@ -28,6 +28,7 @@ import * as omnibox from "@src/background/omnibox" import * as R from "ramda" import * as webrequests from "@src/background/webrequests" import * as commands from "@src/background/commands" +import * as browser_action from "@src/background/browser_action" import * as meta from "@src/background/meta" import * as Logging from "@src/lib/logging" import * as Proxy from "@src/lib/proxy" @@ -244,6 +245,10 @@ browser.tabs.onCreated.addListener(aucon.tabCreatedListener) // An object to collect all of our statistics in one place. const statsLogger: perf.StatsLogger = new perf.StatsLogger() const messages = { + browser_action_background: { + getState: browser_action.getState, + toggle: browser_action.toggle, + }, config_background: { clear: config.clear, pull: config.pull, @@ -288,6 +293,7 @@ omnibox.init() // }}} commands.updateListener() +browser_action.init() // {{{ Obey Mozilla's orders https://github.com/tridactyl/tridactyl/issues/1800 diff --git a/src/background/browser_action.test.ts b/src/background/browser_action.test.ts new file mode 100644 index 00000000..834eea57 --- /dev/null +++ b/src/background/browser_action.test.ts @@ -0,0 +1,43 @@ +import * as config from "@src/lib/config" +import { getState, init, toggle } from "@src/background/browser_action" + +jest.mock("@src/lib/config", () => { + const userconfig = { superignore: "false" } + return { + DEFAULTS: { superignore: "false" }, + USERCONFIG: userconfig, + getAsync: jest.fn().mockResolvedValue(undefined), + set: jest.fn((_key, value) => { + userconfig.superignore = value + return Promise.resolve() + }), + addChangeListener: jest.fn(), + } +}) + +test("browser action toggles superignore without reloading tabs", async () => { + await config.set("superignore", "false") + + init() + await expect(getState()).resolves.toBe("false") + await expect(Promise.all([toggle(), toggle()])).resolves.toEqual([ + "true", + "false", + ]) + expect(config.USERCONFIG.superignore).toBe("false") + expect(browser.browserAction.setBadgeText).toHaveBeenCalledWith({ + text: "OFF", + }) + expect(browser.tabs.reload).not.toHaveBeenCalled() + expect(browser.browserAction.onClicked.addListener).not.toHaveBeenCalled() + expect(browser.browserAction.setTitle).toHaveBeenLastCalledWith({ + title: "Tridactyl enabled", + }) + + jest.mocked(config.set).mockImplementationOnce(async (_key, value) => { + config.USERCONFIG.superignore = value as "true" | "false" + throw new Error("write failed") + }) + await expect(toggle()).rejects.toThrow("write failed") + expect(config.USERCONFIG.superignore).toBe("false") +}) diff --git a/src/background/browser_action.ts b/src/background/browser_action.ts new file mode 100644 index 00000000..4feba3bb --- /dev/null +++ b/src/background/browser_action.ts @@ -0,0 +1,48 @@ +import * as config from "@src/lib/config" + +const ready = config.getAsync() +const superignore = () => + config.USERCONFIG.superignore ?? config.DEFAULTS.superignore + +function updateButton(value) { + const disabled = value === "true" + return Promise.all([ + browser.browserAction.setBadgeText({ text: disabled ? "OFF" : "" }), + browser.browserAction.setTitle({ + title: disabled ? "Tridactyl disabled" : "Tridactyl enabled", + }), + ]) +} + +export async function getState() { + await ready + return superignore() +} + +let toggleQueue: Promise = Promise.resolve() +export function toggle() { + const pending = toggleQueue + .catch(() => undefined) + .then(async () => { + const previous = config.USERCONFIG.superignore + const value = (await getState()) === "true" ? "false" : "true" + try { + await config.set("superignore", value) + } catch (error) { + if (previous === undefined) delete config.USERCONFIG.superignore + else config.USERCONFIG.superignore = previous + throw error + } + updateButton(value).catch(console.error) + return value + }) + toggleQueue = pending + return pending +} + +export function init() { + config.addChangeListener("superignore", (_, value) => + updateButton(value).catch(console.error), + ) + getState().then(updateButton).catch(console.error) +} diff --git a/src/browser_action_popup.test.ts b/src/browser_action_popup.test.ts new file mode 100644 index 00000000..1eede315 --- /dev/null +++ b/src/browser_action_popup.test.ts @@ -0,0 +1,55 @@ +jest.mocked(browser.runtime.sendMessage) + .mockResolvedValueOnce("false") + .mockResolvedValueOnce("true") + +const flush = () => new Promise(resolve => setTimeout(resolve)) + +test("popup separates toggling from reloading the active tab", async () => { + document.body.innerHTML = ` +

+ + ` + jest.mocked(browser.tabs.query).mockResolvedValue([ + { id: 7 } as browser.tabs.Tab, + ]) + jest.mocked(browser.tabs.reload).mockResolvedValue(undefined) + const close = jest.spyOn(window, "close").mockImplementation() + + await import("@src/browser_action_popup") + await flush() + expect(document.querySelector("#state").textContent).toBe( + "Enabled globally", + ) + expect(browser.runtime.sendMessage).toHaveBeenCalledWith({ + type: "browser_action_background", + command: "getState", + args: [], + }) + + document.querySelector("#toggle").click() + expect(document.querySelector("#reload").disabled).toBe( + true, + ) + await flush() + expect(browser.runtime.sendMessage).toHaveBeenCalledWith({ + type: "browser_action_background", + command: "toggle", + args: [], + }) + expect(browser.tabs.reload).not.toHaveBeenCalled() + expect(document.querySelector("#state").textContent).toBe( + "Disabled globally", + ) + + document.querySelector("#reload").click() + expect(document.querySelector("#toggle").disabled).toBe( + true, + ) + await flush() + expect(browser.tabs.query).toHaveBeenCalledWith({ + active: true, + currentWindow: true, + }) + expect(browser.tabs.reload).toHaveBeenCalledWith(7) + expect(close).toHaveBeenCalled() +}) diff --git a/src/browser_action_popup.ts b/src/browser_action_popup.ts new file mode 100644 index 00000000..4504bbd9 --- /dev/null +++ b/src/browser_action_popup.ts @@ -0,0 +1,63 @@ +export {} + +function message(command: "getState" | "toggle") { + return browser.runtime.sendMessage({ + type: "browser_action_background", + command, + args: [], + }) +} + +const state = document.querySelector("#state") +const toggleButton = document.querySelector("#toggle") +const reloadButton = document.querySelector("#reload") + +function showState(value) { + const disabled = value === "true" + state.textContent = disabled ? "Disabled globally" : "Enabled globally" + toggleButton.textContent = disabled + ? "Enable Tridactyl" + : "Disable Tridactyl" +} + +function showError(error) { + state.textContent = `Error: ${error instanceof Error ? error.message : error}` +} + +function setBusy(busy) { + toggleButton.disabled = busy + reloadButton.disabled = busy +} + +async function run(action: () => Promise) { + setBusy(true) + try { + await action() + } catch (error) { + showError(error) + } finally { + setBusy(false) + } +} + +message("getState") + .then(showState) + .then(() => setBusy(false), showError) + +toggleButton.addEventListener("click", () => + run(async () => { + showState(await message("toggle")) + }), +) + +reloadButton.addEventListener("click", () => + run(async () => { + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }) + if (tab?.id === undefined) throw new Error("No active tab found") + await browser.tabs.reload(tab.id) + window.close() + }), +) diff --git a/src/lib/config.ts b/src/lib/config.ts index c6cc544d..8863bf27 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -248,7 +248,7 @@ export class default_config { * * You are usually better off using `blacklistadd` and `seturl [url] noiframe true` as you can then still use some Tridactyl binds, e.g. `shift-insert` for exiting ignore mode. * - * NB: you should only use this with `seturl`. If you get trapped with Tridactyl disabled everywhere just run `tri unset superignore` in the Firefox address bar. If that still doesn't fix things, you can totally reset Tridactyl by running `tri help superignore` in the Firefox address bar, scrolling to the bottom of that page and then clicking "Reset Tridactyl config". + * Use the toolbar popup to toggle this globally. If you get trapped with Tridactyl disabled everywhere just run `tri unset superignore` in the Firefox address bar. If that still doesn't fix things, you can totally reset Tridactyl by running `tri help superignore` in the Firefox address bar, scrolling to the bottom of that page and then clicking "Reset Tridactyl config". */ superignore: "true" | "false" = "false" diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts index b1603ff0..a7a3ab5d 100644 --- a/src/lib/messaging.ts +++ b/src/lib/messaging.ts @@ -20,6 +20,7 @@ export type TabMessageType = | "commandline_frame_ready_to_receive_messages" export type NonTabMessageType = + | "browser_action_background" | "owntab_background" | "excmd_background" | "controller_background" diff --git a/src/manifest.json b/src/manifest.json index dae1797e..358d6853 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -13,6 +13,12 @@ "background.js" ] }, + "browser_action": { + "default_area": "navbar", + "default_icon": "static/logo/Tridactyl_32px.png", + "default_popup": "static/browser_action_popup.html", + "default_title": "Tridactyl controls" + }, "chrome_url_overrides": { "newtab": "static/newtab.html" }, diff --git a/src/static/browser_action_popup.html b/src/static/browser_action_popup.html new file mode 100644 index 00000000..29d95068 --- /dev/null +++ b/src/static/browser_action_popup.html @@ -0,0 +1,21 @@ + + + + + + Tridactyl controls + + + +

Tridactyl

+

Loading...

+ +

+ This setting applies globally. Reload pages to apply it. +

+ + + + diff --git a/src/static/css/browser_action_popup.css b/src/static/css/browser_action_popup.css new file mode 100644 index 00000000..f70c0fc8 --- /dev/null +++ b/src/static/css/browser_action_popup.css @@ -0,0 +1,56 @@ +@import url("../themes/auto/auto.css"); + +body { + box-sizing: border-box; + width: 18rem; + margin: 0; + padding: 1rem; + border-top: 0.25rem solid var(--tridactyl-url-fg); + color: var(--tridactyl-fg); + background: var(--tridactyl-bg); + font-family: var(--tridactyl-font-family-sans); + font-size: var(--tridactyl-font-size); +} + +h1 { + margin: 0 0 0.75rem; + font-family: var(--tridactyl-font-family); + font-size: 1.25rem; +} + +.state { + margin: 0 0 1rem; + font-weight: bold; +} + +button { + box-sizing: border-box; + width: 100%; + padding: 0.65rem; + border: 1px solid var(--tridactyl-fg); + border-radius: var(--tridactyl-status-border-radius); + color: var(--tridactyl-bg); + background: var(--tridactyl-fg); + font: inherit; + cursor: pointer; +} + +button:focus-visible { + outline: 2px solid var(--tridactyl-url-fg); + outline-offset: 2px; +} + +button:disabled { + cursor: wait; + opacity: 0.55; +} + +.secondary { + margin-top: 0.25rem; + color: var(--tridactyl-fg); + background: var(--tridactyl-bg); +} + +.help { + font-size: var(--tridactyl-small-font-size); +}