diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 129ece4..3e2e6b2 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username -buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +buy_me_a_coffee: treeman183 thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/images/kofi_symbol.svg b/images/kofi_symbol.svg new file mode 100644 index 0000000..1e8a6ca --- /dev/null +++ b/images/kofi_symbol.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/importExport.js b/importExport.js index 19b9f4f..f63c899 100644 --- a/importExport.js +++ b/importExport.js @@ -12,7 +12,7 @@ function exportSettings() { chrome.storage.local.get(null, function (localStorage) { const backup = importExportUtils.buildBackupPayload( storage, - localStorage, + importExportUtils.filterLocalSettingsForExport(localStorage), new Date() ); diff --git a/manifest.json b/manifest.json index 5307d46..cfee6d8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Speeder", "short_name": "Speeder", - "version": "5.2.1", + "version": "5.2.4", "manifest_version": 2, "description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")", "homepage_url": "https://github.com/SoPat712/speeder", diff --git a/options.css b/options.css index e4c16a9..871ad93 100644 --- a/options.css +++ b/options.css @@ -1010,17 +1010,85 @@ button.lucide-result-tile.lucide-picked { display: none; } -.support-footer { - padding: 16px 20px; +.support-cta { + margin-top: 14px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--panel-subtle); +} + +.support-cta-text { + margin: 0 0 12px; color: var(--muted); + font-size: 13px; + line-height: 1.45; } -.support-footer p { - margin: 0; +.support-cta-links { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; } -.support-footer a { - font-weight: 600; +.support-cta-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + text-decoration: none; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.support-cta-link:hover { + background: var(--toggle-open-bg); + border-color: var(--toggle-open-border); +} + +.support-cta-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.support-cta-link svg { + width: 24px; + height: 24px; + display: block; +} + +.support-cta-link--kofi { + background: #fff4ef; +} + +.support-cta-link--kofi:hover { + background: #ffe8de; +} + +.support-cta-kofi-img { + display: block; + height: 26px; + width: auto; +} + +.support-cta-link--bmc { + color: #0d0c22; + background: #ffdd00; +} + +.support-cta-link--bmc:hover { + background: #f7d500; +} + +.support-cta-link--bmc svg { + width: 22px; + height: 22px; + display: block; } @media (max-width: 720px) { @@ -1162,4 +1230,21 @@ button.lucide-result-tile.lucide-picked { filter: brightness(0) invert(1); opacity: 0.92; } + + .support-cta-link--kofi { + background: #2c241f; + } + + .support-cta-link--kofi:hover { + background: #3a312a; + } + + .support-cta-link--bmc { + color: #ffdd00; + background: #2a2618; + } + + .support-cta-link--bmc:hover { + background: #3d3510; + } } diff --git a/options.html b/options.html index 189fddf..c3863af 100644 --- a/options.html +++ b/options.html @@ -24,6 +24,72 @@
v
+
+

+ If Speeder has been useful, please consider supporting its development! +

+ +
@@ -701,24 +767,6 @@

-
diff --git a/shared/import-export.js b/shared/import-export.js index a51364d..d98d4df 100644 --- a/shared/import-export.js +++ b/shared/import-export.js @@ -46,6 +46,29 @@ }); } + /** + * Local-only keys excluded from backup JSON. These are disposable caches + * (e.g. Lucide tags.json) that bloat exports and are refetched when needed. + * Keep in sync with lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At"). + */ + var localSettingsKeysOmittedFromExport = [ + "lucideTagsCacheV1", + "lucideTagsCacheV1At" + ]; + + function filterLocalSettingsForExport(local) { + if (!local || typeof local !== "object" || Array.isArray(local)) { + return {}; + } + var out = {}; + for (var key in local) { + if (!Object.prototype.hasOwnProperty.call(local, key)) continue; + if (localSettingsKeysOmittedFromExport.indexOf(key) !== -1) continue; + out[key] = local[key]; + } + return out; + } + function generateBackupFilename(now) { var date = now instanceof Date ? now : new Date(now || Date.now()); var year = date.getFullYear(); @@ -117,6 +140,7 @@ return { buildBackupPayload: buildBackupPayload, extractImportSettings: extractImportSettings, + filterLocalSettingsForExport: filterLocalSettingsForExport, generateBackupFilename: generateBackupFilename, isRecognizedRawSettingsObject: isRecognizedRawSettingsObject, parseImportText: parseImportText diff --git a/tests/helpers/browser.js b/tests/helpers/browser.js index bf864bf..7359392 100644 --- a/tests/helpers/browser.js +++ b/tests/helpers/browser.js @@ -1,7 +1,9 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { JSDOM } from "jsdom"; import { vi } from "vitest"; +import { applyJSDOMWindow } from "./jsdom-globals.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -11,19 +13,73 @@ function readRepoFile(relPath) { return fs.readFileSync(path.join(repoRoot, relPath), "utf8"); } +/** + * Parse HTML into a fresh JSDOM document so tests can reload scripts without + * top-level `const` redeclaration errors (avoids document.write). + */ +export function loadHtmlString(html) { + const dom = new JSDOM(html, { + url: "https://example.org/", + pretendToBeVisual: true, + runScripts: "dangerously" + }); + applyJSDOMWindow(dom.window); +} + export function loadHtml(relPath) { - document.open(); - document.write(readRepoFile(relPath)); - document.close(); + loadHtmlString(readRepoFile(relPath)); +} + +const WINDOW_GLOBAL_SKIP = new Set([ + "alert", + "atob", + "blur", + "btoa", + "cancelAnimationFrame", + "captureEvents", + "clearInterval", + "clearTimeout", + "close", + "confirm", + "fetch", + "focus", + "getComputedStyle", + "matchMedia", + "open", + "prompt", + "queueMicrotask", + "releaseEvents", + "requestAnimationFrame", + "setInterval", + "setTimeout", + "stop" +]); + +function mirrorExtensionGlobalsFromWindow(win) { + if (!win) return; + if (win.tc) { + globalThis.tc = win.tc; + } + for (const key of Object.keys(win)) { + if (WINDOW_GLOBAL_SKIP.has(key)) continue; + if (/^[A-Z]/.test(key)) continue; + const val = win[key]; + if (typeof val === "function") { + globalThis[key] = val; + } + } } export function loadScript(relPath) { - window.eval( + const source = "var chrome = window.chrome || globalThis.chrome;\n" + - readRepoFile(relPath) + - "\n//# sourceURL=" + - relPath - ); + readRepoFile(relPath) + + "\n//# sourceURL=" + + relPath; + const el = document.createElement("script"); + el.textContent = source; + document.head.appendChild(el); + mirrorExtensionGlobalsFromWindow(window); } export async function flushAsyncWork() { diff --git a/tests/helpers/extension-test-utils.js b/tests/helpers/extension-test-utils.js index dba6668..fba54f9 100644 --- a/tests/helpers/extension-test-utils.js +++ b/tests/helpers/extension-test-utils.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { JSDOM } = require("jsdom"); const { vi } = require("vitest"); const ROOT = path.resolve(__dirname, "..", ".."); @@ -18,22 +19,90 @@ function readWorkspaceFile(relPath) { } function loadHtmlFile(relPath) { - document.open(); - document.write(readWorkspaceFile(relPath)); - document.close(); + loadHtmlString(readWorkspaceFile(relPath)); +} + +function applyJSDOMWindow(win) { + globalThis.window = win; + globalThis.document = win.document; + globalThis.navigator = win.navigator; + globalThis.customElements = win.customElements; + globalThis.HTMLElement = win.HTMLElement; + globalThis.Element = win.Element; + globalThis.Node = win.Node; + globalThis.Text = win.Text; + globalThis.DocumentFragment = win.DocumentFragment; + globalThis.Event = win.Event; + globalThis.MouseEvent = win.MouseEvent; + globalThis.KeyboardEvent = win.KeyboardEvent; + globalThis.DOMParser = win.DOMParser; + globalThis.URL = win.URL; + globalThis.Blob = win.Blob; + globalThis.FileReader = win.FileReader; + win.Date = globalThis.Date; + win.open = vi.fn(); + win.close = vi.fn(); } function loadHtmlString(html) { - document.open(); - document.write(html); - document.close(); + const dom = new JSDOM(html, { + url: "https://example.org/", + pretendToBeVisual: true, + runScripts: "dangerously" + }); + applyJSDOMWindow(dom.window); +} + +const WINDOW_GLOBAL_SKIP = new Set([ + "alert", + "atob", + "blur", + "btoa", + "cancelAnimationFrame", + "captureEvents", + "clearInterval", + "clearTimeout", + "close", + "confirm", + "fetch", + "focus", + "getComputedStyle", + "matchMedia", + "open", + "prompt", + "queueMicrotask", + "releaseEvents", + "requestAnimationFrame", + "setInterval", + "setTimeout", + "stop" +]); + +function mirrorExtensionGlobalsFromWindow(win) { + if (!win) return; + if (win.tc) { + globalThis.tc = win.tc; + } + for (const key of Object.keys(win)) { + if (WINDOW_GLOBAL_SKIP.has(key)) continue; + if (/^[A-Z]/.test(key)) continue; + const val = win[key]; + if (typeof val === "function") { + globalThis[key] = val; + } + } } function evaluateScript(relPath) { - const source = readWorkspaceFile(relPath); - window.eval( - `${source}\n//# sourceURL=${workspacePath(relPath).replace(/\\/g, "/")}` - ); + const absPath = workspacePath(relPath); + const source = + "var chrome = window.chrome || globalThis.chrome;\n" + + readWorkspaceFile(relPath) + + `\n//# sourceURL=${absPath.replace(/\\/g, "/")}`; + const el = document.createElement("script"); + el.textContent = source; + document.head.appendChild(el); + mirrorExtensionGlobalsFromWindow(window); } function fireDOMContentLoaded() { diff --git a/tests/helpers/jsdom-globals.js b/tests/helpers/jsdom-globals.js new file mode 100644 index 0000000..26acd91 --- /dev/null +++ b/tests/helpers/jsdom-globals.js @@ -0,0 +1,30 @@ +/** + * Point Vitest/jsdom test globals at a new JSDOM window (no document.write). + * Call after creating `new JSDOM(html, options).window`. + */ +import { vi } from "vitest"; + +export function applyJSDOMWindow(win) { + globalThis.window = win; + globalThis.document = win.document; + globalThis.navigator = win.navigator; + globalThis.customElements = win.customElements; + globalThis.HTMLElement = win.HTMLElement; + globalThis.Element = win.Element; + globalThis.Node = win.Node; + globalThis.Text = win.Text; + globalThis.DocumentFragment = win.DocumentFragment; + globalThis.Event = win.Event; + globalThis.MouseEvent = win.MouseEvent; + globalThis.KeyboardEvent = win.KeyboardEvent; + globalThis.DOMParser = win.DOMParser; + globalThis.URL = win.URL; + globalThis.Blob = win.Blob; + globalThis.FileReader = win.FileReader; + + // Vitest fake timers patch host `Date`; jsdom’s window keeps its own otherwise. + win.Date = globalThis.Date; + + win.open = vi.fn(); + win.close = vi.fn(); +} diff --git a/tests/importExport.integration.test.js b/tests/importExport.integration.test.js index b044070..d1a423a 100644 --- a/tests/importExport.integration.test.js +++ b/tests/importExport.integration.test.js @@ -9,7 +9,9 @@ async function setupImportExport(overrides = {}) { loadHtml("options.html"); globalThis.chrome = createChromeMock(overrides); window.chrome = globalThis.chrome; - globalThis.restore_options = vi.fn(); + const restoreSpy = vi.fn(); + globalThis.restore_options = restoreSpy; + window.restore_options = restoreSpy; loadScript("shared/import-export.js"); loadScript("importExport.js"); await flushAsyncWork(); @@ -24,8 +26,8 @@ describe("import/export flows", () => { sync: { rememberSpeed: true }, local: { customButtonIcons: { faster: { slug: "rocket" } } } }); - const OriginalBlob = globalThis.Blob; - globalThis.Blob = class TestBlob { + const OriginalBlob = window.Blob; + class TestBlob { constructor(parts, options) { this.parts = parts; this.options = options; @@ -34,24 +36,28 @@ describe("import/export flows", () => { async text() { return this.parts.join(""); } - }; + } + globalThis.Blob = TestBlob; + window.Blob = TestBlob; let capturedBlob = null; let clickedDownload = null; - Object.defineProperty(URL, "createObjectURL", { + Object.defineProperty(window.URL, "createObjectURL", { configurable: true, value: vi.fn((blob) => { capturedBlob = blob; return "blob:test"; }) }); - Object.defineProperty(URL, "revokeObjectURL", { + Object.defineProperty(window.URL, "revokeObjectURL", { configurable: true, value: vi.fn(() => {}) }); - vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function () { - clickedDownload = this.download; - }); + vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation( + function () { + clickedDownload = this.download; + } + ); document.getElementById("exportSettings").click(); @@ -70,6 +76,55 @@ describe("import/export flows", () => { expect(chrome.storage.sync.get).toHaveBeenCalled(); expect(chrome.storage.local.get).toHaveBeenCalled(); globalThis.Blob = OriginalBlob; + window.Blob = OriginalBlob; + }); + + it("export strips lucideTagsCacheV1 from localSettings", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10)); + await setupImportExport({ + sync: { rememberSpeed: true }, + local: { + customButtonIcons: { faster: { slug: "rocket", svg: "" } }, + lucideTagsCacheV1: { "a-arrow-down": ["letter"] }, + lucideTagsCacheV1At: 42 + } + }); + const OriginalBlob = window.Blob; + class TestBlob { + constructor(parts) { + this.parts = parts; + } + async text() { + return this.parts.join(""); + } + } + globalThis.Blob = TestBlob; + window.Blob = TestBlob; + let capturedBlob = null; + Object.defineProperty(window.URL, "createObjectURL", { + configurable: true, + value: vi.fn((blob) => { + capturedBlob = blob; + return "blob:test"; + }) + }); + Object.defineProperty(window.URL, "revokeObjectURL", { + configurable: true, + value: vi.fn(() => {}) + }); + vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation( + () => {} + ); + + document.getElementById("exportSettings").click(); + await flushAsyncWork(); + + expect(JSON.parse(await capturedBlob.text()).localSettings).toEqual({ + customButtonIcons: { faster: { slug: "rocket", svg: "" } } + }); + globalThis.Blob = OriginalBlob; + window.Blob = OriginalBlob; }); it("imports wrapped backup payloads and refreshes options", async () => { @@ -87,7 +142,7 @@ describe("import/export flows", () => { return el; }); - globalThis.FileReader = class MockFileReader { + class MockFileReader { readAsText(file) { this.onload({ target: { @@ -95,7 +150,9 @@ describe("import/export flows", () => { } }); } - }; + } + globalThis.FileReader = MockFileReader; + window.FileReader = MockFileReader; globalThis.importSettings(); createdInput.onchange({ @@ -145,7 +202,7 @@ describe("import/export flows", () => { return el; }); - globalThis.FileReader = class MockFileReader { + class MockFileReader { readAsText(file) { this.onload({ target: { @@ -153,7 +210,9 @@ describe("import/export flows", () => { } }); } - }; + } + globalThis.FileReader = MockFileReader; + window.FileReader = MockFileReader; globalThis.importSettings(); createdInput.onchange({ @@ -200,7 +259,7 @@ describe("import/export flows", () => { return el; }); - globalThis.FileReader = class MockFileReader { + class MockFileReader { readAsText(file) { this.onload({ target: { @@ -208,7 +267,9 @@ describe("import/export flows", () => { } }); } - }; + } + globalThis.FileReader = MockFileReader; + window.FileReader = MockFileReader; globalThis.importSettings(); createdInput.onchange({ @@ -247,7 +308,7 @@ describe("import/export flows", () => { return el; }); - globalThis.FileReader = class MockFileReader { + class MockFileReader { readAsText(file) { this.onload({ target: { @@ -255,7 +316,9 @@ describe("import/export flows", () => { } }); } - }; + } + globalThis.FileReader = MockFileReader; + window.FileReader = MockFileReader; globalThis.importSettings(); createdInput.onchange({ diff --git a/tests/importExport.spec.js b/tests/importExport.spec.js index 8694b31..6adc500 100644 --- a/tests/importExport.spec.js +++ b/tests/importExport.spec.js @@ -87,6 +87,36 @@ describe("importExport.js", () => { expect(document.querySelector("#status").textContent).toContain("exported"); }); + it("omits Lucide tags cache from exported localSettings", async () => { + vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation( + () => {} + ); + const { createObjectURL } = bootImportExport({ + syncData: { rememberSpeed: true }, + localData: { + customButtonIcons: { + faster: { slug: "rocket", svg: "" } + }, + lucideTagsCacheV1: { "a-arrow-down": ["letter", "text"] }, + lucideTagsCacheV1At: 999 + } + }); + + document.querySelector("#exportSettings").click(); + await flushAsyncWork(); + + const blob = createObjectURL.mock.calls[0][0]; + const backup = JSON.parse(await blob.text()); + + expect(backup.localSettings).toEqual({ + customButtonIcons: { + faster: { slug: "rocket", svg: "" } + } + }); + expect(backup.localSettings.lucideTagsCacheV1).toBeUndefined(); + expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined(); + }); + it("imports wrapped backups, restores local data, and refreshes the options page", async () => { const { chrome } = bootImportExport(); window.restore_options = vi.fn(); diff --git a/tests/inject.test.js b/tests/inject.test.js index fcb3eff..59ba607 100644 --- a/tests/inject.test.js +++ b/tests/inject.test.js @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import { createChromeMock, flushAsyncWork, loadScript } from "./helpers/browser.js"; +import { + createChromeMock, + flushAsyncWork, + loadHtmlString, + loadScript +} from "./helpers/browser.js"; function loadBlankDocument() { - document.open(); - document.write(""); - document.close(); + loadHtmlString(""); } async function bootInject({ sync = {}, local = {} } = {}) { diff --git a/tests/setup.js b/tests/setup.js index b544fb8..e33ec0a 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -16,7 +16,11 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); delete globalThis.SpeederShared; - delete globalThis.restore_options; + try { + delete globalThis.restore_options; + } catch { + globalThis.restore_options = undefined; + } if (typeof document !== "undefined") { document.head.innerHTML = ""; document.body.innerHTML = ""; diff --git a/tests/shared.test.js b/tests/shared.test.js index 70c9b69..c9054f6 100644 --- a/tests/shared.test.js +++ b/tests/shared.test.js @@ -149,5 +149,15 @@ describe("shared helpers", () => { expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe( false ); + + expect( + importExportUtils.filterLocalSettingsForExport({ + customButtonIcons: { faster: { slug: "zap" } }, + lucideTagsCacheV1: { "a-arrow-down": ["letter"] }, + lucideTagsCacheV1At: 123 + }) + ).toEqual({ + customButtonIcons: { faster: { slug: "zap" } } + }); }); });