From 7ed771de6abcfef981c7ae720edc3a9fbd2c14a9 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Thu, 30 May 2024 00:39:29 +0100 Subject: [PATCH] extension: switch to scripting api, should work with chrome mv3 now! for firefox mv3 still need some work for permissions --- extension/src/background.ts | 250 ++++++++++++++++++--------------- extension/src/common.ts | 25 ++-- extension/src/compat.ts | 24 ++++ extension/src/filterlist.ts | 12 +- extension/src/notifications.ts | 41 +++--- extension/src/showvisited.js | 4 +- tests/end2end_test.py | 14 +- 7 files changed, 217 insertions(+), 153 deletions(-) create mode 100644 extension/src/compat.ts diff --git a/extension/src/background.ts b/extension/src/background.ts index 469a7887..3d7d58b0 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -4,6 +4,7 @@ import type {Action, BrowserAction, Menus, PageAction, Runtime, Tabs, WebNavigat import type {Url, SearchPageParams} from './common' import {Visit, Visits, Blacklisted, Methods, assert, uuid} from './common' +import {executeScript} from './compat' import type {Options} from './options' import {Toggles, getOptions, setOption, THIS_BROWSER_TAG} from './options' @@ -140,10 +141,11 @@ async function updateState(tab: TabUrl): Promise { // todo only inject after blacklist check? just in case? let proceed: boolean try { - await browser.tabs.insertCSS (tabId, {file: 'sidebar-outer.css'}) - await browser.tabs.insertCSS (tabId, {file: 'sidebar.css' }) - await browser.tabs.insertCSS (tabId, {code: opts.position_css }) - await browser.tabs.executeScript(tabId, {file: 'sidebar.js'}) + const target = {tabId: tabId} + await browser.scripting.insertCSS ({target: target, files: ['sidebar-outer.css']}) + await browser.scripting.insertCSS ({target: target, files: ['sidebar.css' ]}) + await browser.scripting.insertCSS ({target: target, css: opts.position_css }) + await browser.scripting.executeScript({target: target, files: ['sidebar.js' ]}) proceed = true // successful code injection } catch (error) { const msg = (error as Error).message @@ -313,33 +315,38 @@ async function filter_urls(urls: Array) { } + async function doToggleMarkVisited(tabId: number, {show}: {show: boolean | null} = {show: null}) { // first check if we need to disable TODO - const _should_show = await browser.tabs.executeScript(tabId, { - code: ` -{ - let res // ?boolean - let show = ${show == null ? 'null' : String(show)} - const shown = window.promnesiaShowsVisits || false - if (show == null) { - // we want the opposite - show = !shown - } - if (show === shown) { - res = null // no change - } else if (show) { - res = true // should show - window.promnesiaShowsVisits = true // ugh. set early to avoid race conditions... - } else {// - res = false // should hide - setTimeout(() => hideMarks()) // async to return straightaway - window.promnesiaShowsVisits = false - } - res -} -`}) - const should_show: boolean | null = _should_show![0] - if (should_show == null) { + const target = {tabId: tabId} + const should_show = await executeScript({ + target: target, + func: (show: boolean) => { + let res: boolean | null + // @ts-expect-error + const shown = window.promnesiaShowsVisits || false + if (show == null) { + // we want the opposite + show = !shown + } + if (show === shown) { + res = null // no change + } else if (show) { + res = true // should show + // @ts-expect-error + window.promnesiaShowsVisits = true // ugh. set early to avoid race conditions... + } else { + res = false // should hide + // @ts-expect-error hideMarks is declared in showvisited.js + setTimeout(() => hideMarks()) // async to return straightaway + // @ts-expect-error + window.promnesiaShowsVisits = false + } + return res + }, + args: [show], + }) + if (should_show === null) { console.debug('requested state %s: no change needed', show) return } else if (should_show === false) { @@ -348,22 +355,23 @@ async function doToggleMarkVisited(tabId: number, {show}: {show: boolean | null} } // collect URLS from the page - const mresults = await browser.tabs.executeScript(tabId, { - code: ` - // NOTE: important to make a snapshot here.. otherwise might go in an infinite loop - link_elements = Array.from(document.getElementsByTagName("a")) - link_elements.map(el => { - try { - // handle relative urls - return new URL(el.href, document.baseURI).href - } catch { - return null + const results = await executeScript>({ + target: target, + func: () => { + // NOTE: important to make a snapshot here.. otherwise might go in an infinite loop + const links = Array.from(document.getElementsByTagName("a")) + // @ts-expect-error + window.link_elements = links + return links.map(el => { + try { + // handle relative urls + return new URL(el.href, document.baseURI).href + } catch { + return null + } + }) } - }) - ` -}) - // not sure why it's returning array.. - const results: Array = mresults![0] + }) const page_urls = Array.from(await filter_urls(results)) const resp = await allsources.visited(page_urls) if (resp instanceof Error) { @@ -410,21 +418,24 @@ async function doToggleMarkVisited(tabId: number, {show}: {show: boolean | null} visited.delete(url) } } - // todo ugh. errors inside the script (e.g. syntax errors) get swallowed.. - // TODO not sure.. probably need to inject the script once and then use a message listener or something like in sidebar?? - await browser.tabs.insertCSS(tabId, { - file: 'showvisited.css', - }) - await browser.tabs.executeScript(tabId, { - file: 'showvisited.js', - }) - await browser.tabs.executeScript(tabId, { - code: ` -visited = new Map(JSON.parse(${JSON.stringify(JSON.stringify([...visited]))})) -setTimeout(() => showMarks()) -// best to set it in case of partial processing -window.promnesiaShowsVisits = true -` + await browser.scripting.insertCSS ({target: target, files: ['showvisited.css']}) + await executeScript({target: target, files: ['showvisited.js' ]}) + await executeScript({ + target: target, + func: (visited_entries: Array<[Url, any]>) => { + // FIXME ok, the only thing I'm not sure about is how it preserves Visit objects? + // but I suspect this is consistent with previous version of code anyway + const visited = new Map(visited_entries) + // @ts-expect-error + window.visited = visited + // @ts-expect-error + setTimeout(() => showMarks()) + // best to set it in case of partial processing + // @ts-expect-error + window.promnesiaShowsVisits = true + }, + // ugh. need to make sure it's json serializable + args: [Array.from(visited, ([key, value]) => [key, value.toJObject()])], }) } @@ -701,24 +712,28 @@ async function active(): Promise { async function globalExcludelistPrompt(): Promise> { // NOTE: needs to take active tab becaue tab isn't present in the 'info' object if it was clicked from the launcher etc. const {id: tabId, url: url} = await active() - const prompt = `Global excludelist. Supported formats: + const prompt_msg = `Global excludelist. Supported formats: - domain.name, e.g.: web.telegram.org Will exclude whole Telegram website. - http://exact/match, e.g.: http://github.com Will only exclude Github main page. Subpages will still work. - /regul.r.*expression/, e.g.: /github.*/yourusername/ Quick way to exclude your own Github repostitories. -`; +` // ugh. won't work for multiple urls, prompt can only be single line... - const res = await browser.tabs.executeScript(tabId, { - code: `prompt(\`${prompt}\`, "${url}");` + const res = await executeScript({ + target: {tabId: tabId}, + func: (prompt_msg: string, url: string) => { + return prompt(prompt_msg, url) + }, + args: [prompt_msg, url], }) - if (res == null || res[0] == null) { + if (res == null) { console.info('user chose not to add %s', url) return [] } - return [res[0]] + return [res] } async function handleAddToGlobalExcludelist() { @@ -748,56 +763,61 @@ const AddToMarkVisitedExcludelist = { // TODO only call prompts if more than one? sort before showing? const {id: tabId, url: _url} = await active() - await browser.tabs.executeScript(tabId, { - code: `{ -let listener = e => { - e.stopPropagation() - - const tgt = e.target - const old = tgt.style.outline - - tgt.addEventListener('mouseout', e => { - tgt.style.outline = old - }) - // display zapper frame - tgt.style.outline = '4px solid #07C' - // todo use css class? -} -document.addEventListener('mouseover', listener) - -document.addEventListener('click', e => { - // console.error("CLiCK!!! %o", e) - document.removeEventListener('mouseover', listener) - - // FIXME ugh. it also captures file:// links and javascript: - // should't traverse inside promnesia clases... - let links = Array.from(e.target.getElementsByTagName('a')).map(el => { - const href = el.href - if (href == null) { - return null - } - try { - // handle relative urls - return new URL(href, document.baseURI).href - } catch (e) { - console.error(e) - return null - } - }).filter(e => e != null) - links = [...new Set(links)].sort() // make unique - // - chrome.runtime.sendMessage({method: '${Methods.ZAPPER_EXCLUDELIST}', data: links}) -}) -let cancel = e => { - // console.error("ESCAPE!!!, %o", e) - if (e.key == 'Escape') { - document.removeEventListener('mouseover', listener) - window.removeEventListener('keydown', cancel) - } -} -window.addEventListener('keydown', cancel) - -}`}) + // TODO ugh at this point could just move to external file? + await executeScript({ + target: {tabId: tabId}, + func: (method_zapper_excludelist: string) => { + const listener = (e: MouseEvent) => { + e.stopPropagation() + + const tgt = e.target! + // @ts-expect-error + const tgt_style = tgt.style + const old = tgt_style.outline + + tgt.addEventListener('mouseout', (_e) => { + tgt_style.outline = old + }) + // display zapper frame + tgt_style.outline = '4px solid #07C' + // todo use css class? + } + document.addEventListener('mouseover', listener) + + document.addEventListener('click', (e: MouseEvent) => { + document.removeEventListener('mouseover', listener) + + // FIXME ugh. it also captures file:// links and javascript: + // should't traverse inside promnesia clases... + // @ts-expect-error + let links = Array.from(e.target.getElementsByTagName('a')).map((el: HTMLAnchorElement) => { + const href = el.href + if (href == null) { + return null + } + try { + // handle relative urls + return new URL(href, document.baseURI).href + } catch (e) { + console.error(e) + return null + } + }).filter(e => e != null) + links = Array.from(new Set(links)).sort() // make unique + // @ts-expect-error + chrome.runtime.sendMessage({method: method_zapper_excludelist, data: links}) + }) + const cancel = (e: KeyboardEvent) => { + // console.error("ESCAPE!!!, %o", e) + if (e.key == 'Escape') { + document.removeEventListener('mouseover', listener) + window.removeEventListener('keydown', cancel) + } + } + window.addEventListener('keydown', cancel) + }, + args: [Methods.ZAPPER_EXCLUDELIST], + }) }, handleZapperResult: async function(msg: any) { const urls: Array = msg.data diff --git a/extension/src/common.ts b/extension/src/common.ts index 06de18d1..5e15053b 100644 --- a/extension/src/common.ts +++ b/extension/src/common.ts @@ -67,9 +67,9 @@ export class Visit { const o = {} Object.assign(o, this) // @ts-expect-error - o.time = o.time .getTime() + o.time = o.time .toJSON() // @ts-expect-error - o.dt_local = o.dt_local.getTime() + o.dt_local = o.dt_local.toJSON() return o } @@ -247,17 +247,17 @@ export type Json = JsonArray | JsonObject export function getBrowser(): string { // https://stackoverflow.com/questions/12489546/getting-a-browsers-name-client-side - const agent = window.navigator.userAgent.toLowerCase() + const agent = navigator.userAgent.toLowerCase() switch (true) { // @ts-expect-error - case agent.indexOf("chrome") > -1 && !! window.chrome: return "chrome"; - case agent.indexOf("firefox") > -1 : return "firefox"; - case agent.indexOf("safari") > -1 : return "safari"; - case agent.indexOf("edge") > -1 : return "edge"; + case agent.indexOf("chrome") > -1 && !! chrome: return "chrome" + case agent.indexOf("firefox") > -1 : return "firefox" + case agent.indexOf("safari") > -1 : return "safari" + case agent.indexOf("edge") > -1 : return "edge" // @ts-expect-error - case agent.indexOf("opr") > -1 && !!window.opr : return "opera"; - case agent.indexOf("trident") > -1 : return "ie"; - default: return "browser"; + case agent.indexOf("opr") > -1 && !!opr : return "opera" + case agent.indexOf("trident") > -1 : return "ie" + default: return "browser" } } @@ -317,8 +317,11 @@ export async function fetch_max_stale(url: string, {max_stale}: {max_stale: numb // useful for debugging +// borrowed from https://stackoverflow.com/a/2117523/706389 export function uuid(): string { - return URL.createObjectURL(new Blob([])).substr(-36) + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => + (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) + ) } diff --git a/extension/src/compat.ts b/extension/src/compat.ts new file mode 100644 index 00000000..acfe5ddc --- /dev/null +++ b/extension/src/compat.ts @@ -0,0 +1,24 @@ +import browser from "webextension-polyfill" +import type {Scripting} from "webextension-polyfill" + +import {assert} from './common' + + +export async function executeScript(injection: Scripting.ScriptInjection): Promise { + /** + * In firefox, executeScript sets error property, whereas in chrome it just throws + * (see https://issues.chromium.org/issues/40205757) + * For consistency, this wrapper throws in all cases instead + */ + const results = await browser.scripting.executeScript(injection) + assert(results.length == 1) + const [{result, error}] = results + if (error != null) { + if (error instanceof Error) { + throw error + } else { + throw new Error(`Error during executeScript: ${error}`) + } + } + return result +} diff --git a/extension/src/filterlist.ts b/extension/src/filterlist.ts index cd0ed544..d9ad7c92 100644 --- a/extension/src/filterlist.ts +++ b/extension/src/filterlist.ts @@ -67,12 +67,12 @@ export class Filterlist { console.debug('loading %s from %s', name, url) - // clear old basket.js local storge - for (const key of Object.keys(localStorage)) { - if (key.startsWith('basket-')) { - localStorage.removeItem(key) - } - } + // clear old basket.js local storge // FIXME not sure? + // for (const key of Object.keys(localStorage)) { + // if (key.startsWith('basket-')) { + // localStorage.removeItem(key) + // } + // } if (url.includes('/cbuijs/shallalist/')) { // use my fork just in case... they stopped updating the list anyway diff --git a/extension/src/notifications.ts b/extension/src/notifications.ts index 5721b3d0..abc69ec3 100644 --- a/extension/src/notifications.ts +++ b/extension/src/notifications.ts @@ -2,6 +2,7 @@ import browser from "webextension-polyfill" import type {Url} from './common' import {Blacklisted} from './common' +import {executeScript} from './compat' import {getOptions} from './options' import type {Options} from './options' @@ -73,27 +74,35 @@ type ToastOptions = { duration_ms?: number, } -export async function _showTabNotification(tabId: number, text: string, {color: color, duration_ms: duration_ms}: ToastOptions) { +export async function _showTabNotification(tabId: number, text: string, {color, duration_ms}: ToastOptions) { color = color || 'green' duration_ms = duration_ms || 2 * 1000 // TODO can it be remote script? - text = text.replace(/\n/g, "\\n"); // .... - await browser.tabs.executeScript(tabId, {file: 'toastify.js'}) - await browser.tabs.insertCSS(tabId, {file: 'toastify.css'}); + // FIXME maybe use async import? + + const target = {tabId: tabId} + + await executeScript({target: target, files: ['toastify.js']}) + await browser.scripting.insertCSS ({target: target, files: ['toastify.css']}) // TODO extract toast settings somewhere... - await browser.tabs.executeScript(tabId, { code: ` -Toastify({ - text: "${text}", - duration: ${duration_ms}, - newWindow: true, - close: true, - stopOnFocus: true, // prevent dismissing on hover - gravity: "top", - positionLeft: false, - backgroundColor: "${color}", -}).showToast(); - ` }); + await executeScript({ + target: target, + func: (text: string, duration_ms: number, color: string) => { + // @ts-expect-error + Toastify({ + text: text, + duration: duration_ms, + newWindow: true, + close: true, + stopOnFocus: true, // prevent dismissing on hover + gravity: 'top', + positionLeft: false, + backgroundColor: color, + }).showToast() + }, + args: [text, duration_ms, color], + }) // todo ugh. close icon is shown on the bottom?.. } diff --git a/extension/src/showvisited.js b/extension/src/showvisited.js index 44c97fea..ae37ed60 100644 --- a/extension/src/showvisited.js +++ b/extension/src/showvisited.js @@ -78,7 +78,7 @@ function formatVisit(v) { } const e_at = document.createElement('span') e_at.classList.add('datetime') - e_at.textContent = `${new Date(dt).toLocaleString()}` + e_at.textContent = new Date(dt).toLocaleString() e.appendChild(e_at) return e } @@ -121,7 +121,7 @@ function showMark(element) { const url = element.href // 'visited' passed in backgroud.js // eslint-disable-next-line no-undef - const v = visited.get(url) + const v = window.visited.get(url) if (!v) { return // no visits or was excluded (add some data attribute maybe?) } diff --git a/tests/end2end_test.py b/tests/end2end_test.py index 5c6abdaf..c3e9b29b 100755 --- a/tests/end2end_test.py +++ b/tests/end2end_test.py @@ -773,13 +773,21 @@ def test_showvisits_popup(addon: Addon, driver: Driver, backend: Backend) -> Non ) addon.move_to(link_with_popup) # hover over visited mark # meh, but might need some time to render.. - popup = Wait(driver, timeout=5).until( + popup_context = Wait(driver, timeout=5).until( EC.presence_of_element_located((By.CLASS_NAME, 'context')), ) sleep(3) # text might take some time to render too.. - assert popup.text == 'some comment' + assert is_visible(driver, popup_context) + assert popup_context.text == 'some comment' - assert is_visible(driver, popup) + popup_datetime = Wait(driver, timeout=5).until( + EC.presence_of_element_located((By.CLASS_NAME, 'datetime')) + ) + assert is_visible(driver, popup_datetime) + assert popup_datetime.text in { + '10/09/2014, 00:00:00', + '9/10/2014, 12:00:00 AM', # TODO ugh. github actions has a different locale.. + } @browsers()