From 9e4eb44872d0b115df7fcdf4d66f91778f720f2e Mon Sep 17 00:00:00 2001 From: tophf Date: Mon, 21 Oct 2024 14:34:54 +0300 Subject: [PATCH] WIP sync --- src/background-sw/index.js | 8 ++ src/background-sw/set-client-data.js | 6 +- src/background/db-to-cloud-broker.js | 7 ++ src/background/download.js | 7 +- src/background/index.js | 2 +- src/background/intro.js | 2 +- src/background/sync-manager.js | 47 +++++----- src/background/token-manager.js | 108 ++++++++++++----------- src/background/usercss-install-helper.js | 23 +++-- src/background/usw-api.js | 3 +- src/content/apply.js | 2 +- src/edit/autocomplete.js | 2 +- src/edit/linter/dialogs.js | 6 +- src/edit/linter/engines.js | 2 +- src/edit/linter/store.js | 5 -- src/edit/source-editor.js | 2 +- src/edit/util.js | 5 ++ src/js/consts.js | 4 + src/js/dnr.js | 13 ++- src/js/msg-api.js | 69 +++++++++++++++ src/js/msg-base.js | 75 +--------------- src/js/msg.js | 3 +- src/js/port.js | 18 ++-- src/js/urls.js | 1 + src/js/util-base.js | 12 +++ src/manage/import-export.js | 5 +- src/offscreen/index.js | 15 +++- src/options/options-sync.js | 12 +-- src/options/options.css | 1 + tools/util.js | 7 +- webpack.config.js | 44 +++++---- 31 files changed, 287 insertions(+), 229 deletions(-) create mode 100644 src/background/db-to-cloud-broker.js create mode 100644 src/js/consts.js create mode 100644 src/js/msg-api.js diff --git a/src/background-sw/index.js b/src/background-sw/index.js index c0a753bb9e..1b4b8e7081 100644 --- a/src/background-sw/index.js +++ b/src/background-sw/index.js @@ -1,5 +1,6 @@ // WARNING! /background must be the first to set global.API import '/background'; +import {cloudDrive} from '/background/db-to-cloud-broker'; import {API, _execute} from '/js/msg'; import {createPortProxy, initRemotePort} from '/js/port'; import {workerPath, ownRoot} from '/js/urls'; @@ -42,3 +43,10 @@ self.onfetch = evt => { } API.worker = createPortProxy(() => offscreen.getWorkerPort(workerPath), workerPath); + +cloudDrive.webdav = async cfg => { + const res = await offscreen.webdavInit(cfg); + const webdav = offscreen.webdav; + for (const k in res) res[k] ??= webdav.bind(null, k); + return res; +}; diff --git a/src/background-sw/set-client-data.js b/src/background-sw/set-client-data.js index 7b57721f4e..5454eb5d49 100644 --- a/src/background-sw/set-client-data.js +++ b/src/background-sw/set-client-data.js @@ -3,7 +3,7 @@ import {isVivaldi} from '/background/common'; import prefsApi from '/background/prefs-api'; import * as styleMan from '/background/style-manager'; import * as syncMan from '/background/sync-manager'; -import {API} from '/js/msg-base'; +import {API} from '/js/msg-api'; import * as prefs from '/js/prefs'; import {FIREFOX} from '/js/ua'; @@ -54,5 +54,7 @@ export default async function setClientData(evt, reqUrl) { v = await Promise.all(Object.values(jobs)); Object.keys(jobs).forEach((id, i) => (jobs[id] = v[i])); - return new Response(`var clientData = ${JSON.stringify(jobs)}`, NO_CACHE); + return new Response(`var clientData = new Proxy(${JSON.stringify(jobs)}, {get: ${(obj, k, _) => (( + (_ = obj[k]), delete obj[k], _ + ))}})`, NO_CACHE); } diff --git a/src/background/db-to-cloud-broker.js b/src/background/db-to-cloud-broker.js new file mode 100644 index 0000000000..6a6b388973 --- /dev/null +++ b/src/background/db-to-cloud-broker.js @@ -0,0 +1,7 @@ +import dropbox from 'db-to-cloud/lib/drive/dropbox'; +import onedrive from 'db-to-cloud/lib/drive/onedrive'; +import google from 'db-to-cloud/lib/drive/google'; +import webdav from 'db-to-cloud/lib/drive/webdav'; + +export const cloudDrive = {dropbox, onedrive, google, webdav: !process.env.MV3 && webdav}; +export {dbToCloud} from 'db-to-cloud/lib/db-to-cloud'; diff --git a/src/background/download.js b/src/background/download.js index f01c9a0e11..bda0efb57f 100644 --- a/src/background/download.js +++ b/src/background/download.js @@ -1,3 +1,4 @@ +import {kAppUrlencoded, kContentType} from '/js/consts'; import {tryJSONparse, URLS} from '/js/toolbox'; /** @type {Record}>} */ @@ -63,9 +64,7 @@ async function doDownload(url, { } else if (method === 'GET' && url.length >= 2000 && url.startsWith(URLS.usoJson)) { url = collapseUsoVars(usoVars = [], url, i); } - headers ??= { - 'Content-type': 'application/x-www-form-urlencoded', - }; + headers ??= {[kContentType]: kAppUrlencoded}; } /** @type {Response | XMLHttpRequest} */ const resp = process.env.MV3 @@ -193,7 +192,7 @@ async function fetchWithProgress(resp, responseType, headers, jobKey) { } } if (responseType === 'blob') { - data = new Blob([data], {type: headers.get('Content-Type')}); + data = new Blob([data], {type: headers.get(kContentType)}); } else if (responseType === 'arraybuffer') { data = data.buffer; } else { diff --git a/src/background/index.js b/src/background/index.js index f8845a698c..6c257bc562 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -26,7 +26,7 @@ import * as usercssMan from './usercss-manager'; import * as usoApi from './uso-api'; import * as uswApi from './usw-api'; -Object.assign(API, /** @namespace API */ { +Object.assign(API, { //#region API data/db/info diff --git a/src/background/intro.js b/src/background/intro.js index aff006b431..b4e43fa3f5 100644 --- a/src/background/intro.js +++ b/src/background/intro.js @@ -1 +1 @@ -global.API = {}; // will be used by msg.js +process.env.API = {}; // `global.API` will be used by msg.js diff --git a/src/background/sync-manager.js b/src/background/sync-manager.js index b82998e853..fb951b3aa9 100644 --- a/src/background/sync-manager.js +++ b/src/background/sync-manager.js @@ -1,13 +1,16 @@ import browser from '/js/browser'; +import {kGetAccessToken} from '/js/consts'; +import {API} from '/js/msg-api'; import * as prefs from '/js/prefs'; import {chromeLocal, chromeSync} from '/js/storage-util'; +import {fetchWebDAV, hasOwn} from '/js/util-base'; import {broadcastExtension} from './broadcast'; import {bgReady, uuidIndex} from './common'; import db from './db'; +import {cloudDrive, dbToCloud} from './db-to-cloud-broker'; import {overrideBadge} from './icon-manager'; import * as styleMan from './style-manager'; import {getToken, revokeToken} from './token-manager'; -import * as dbToCloud from 'db-to-cloud'; //#region Init @@ -99,11 +102,12 @@ export async function getDriveOptions(driveName) { export async function start(name, fromPref = false) { if (ready.then) await ready; - if (!ctrl) await initController(); - else if (ctrl.then) await ctrl; + if ((ctrl ??= initController()).then) ctrl = await ctrl; if (currentDrive) return; - currentDrive = await getDrive(name); + if ((currentDrive = getDrive(name)).then) { // preventing re-entry by assigning synchronously + currentDrive = await currentDrive; + } ctrl.use(currentDrive); status.state = STATES.connecting; @@ -176,8 +180,8 @@ export async function syncNow() { //#endregion //#region Utils -async function initController() { - ctrl = await (ctrl = dbToCloud.dbToCloud({ +function initController() { + return dbToCloud({ onGet: _id => styleMan.uuid2style(_id) || uuidIndex.custom[_id], async onPut(doc) { if (!doc) return; // TODO: delete it? @@ -230,7 +234,7 @@ async function initController() { retryMaxAttempts: 10, retryExp: 1.2, retryDelay: 6, - })); + }); } function emitStatusChange() { @@ -269,22 +273,19 @@ function getErrorBadge() { } async function getDrive(name) { - if (name === 'dropbox' || name === 'google' || name === 'onedrive' || name === 'webdav') { - const options = await getDriveOptions(name); - options.getAccessToken = () => getToken(name); - options.fetch = name === 'webdav' ? fetchWebDAV.bind(options) : fetch; - return dbToCloud.drive[name].default(options); + if (!hasOwn(cloudDrive, name)) throw new Error(`Unknown cloud provider: ${name}`); + const options = await getDriveOptions(name); + const webdav = name === 'webdav'; + const getAccessToken = () => getToken(name); + if (!process.env.MV3) { + options[kGetAccessToken] = getAccessToken; + options.fetch = webdav ? fetchWebDAV.bind(options) : fetch; + } else if (webdav) { + API.sync[kGetAccessToken] = getAccessToken; + } else { + options[kGetAccessToken] = getAccessToken; } - throw new Error(`unknown cloud name: ${name}`); -} - -/** @this {Object} DriveOptions */ -function fetchWebDAV(url, init = {}) { - init.credentials = 'omit'; // circumventing nextcloud CSRF token error - init.headers = Object.assign({}, init.headers, { - Authorization: `Basic ${btoa(`${this.username || ''}:${this.password || ''}`)}`, - }); - return fetch(url, init); + return cloudDrive[name](options); } function schedule(delay = SYNC_DELAY) { @@ -299,7 +300,7 @@ function translateErrorMessage(err) { return browser.i18n.getMessage('syncErrorLock', new Date(err.expire).toLocaleString([], {timeStyle: 'short'})); } - return err.message || String(err); + return err.message || JSON.stringify(err); } //#endregion diff --git a/src/background/token-manager.js b/src/background/token-manager.js index 4104db0a0d..f0d9e2298c 100644 --- a/src/background/token-manager.js +++ b/src/background/token-manager.js @@ -1,8 +1,9 @@ -import launchWebAuthFlow from 'webext-launch-web-auth-flow'; -import {browserWindows, clamp, FIREFOX, URLS} from '/js/toolbox'; +import {kAppUrlencoded, kContentType} from '/js/consts'; +import {DNR_ID_IDENTITY, updateDNR} from '/js/dnr'; import {chromeLocal} from '/js/storage-util'; +import {browserWindows, clamp, FIREFOX, URLS} from '/js/toolbox'; import {isVivaldi} from './common'; -import {waitForTabUrl} from './tab-util'; +import launchWebAuthFlow from 'webext-launch-web-auth-flow'; const AUTH = { dropbox: { @@ -139,10 +140,11 @@ async function refreshToken(name, k, obj) { async function authUser(keys, name, interactive = false, hooks = null) { const provider = AUTH[name]; const state = Math.random().toFixed(8).slice(2); + const customRedirectUri = provider.redirect_uri; const query = { response_type: provider.flow, client_id: provider.clientId, - redirect_uri: provider.redirect_uri || DEFAULT_REDIRECT_URI, + redirect_uri: customRedirectUri || DEFAULT_REDIRECT_URI, state, }; if (provider.scopes) { @@ -151,29 +153,10 @@ async function authUser(keys, name, interactive = false, hooks = null) { if (provider.authQuery) { Object.assign(query, provider.authQuery); } - if (alwaysUseTab == null) { - alwaysUseTab = await detectVivaldiWebRequestBug(); - } - if (hooks) hooks.query(query); + hooks?.query(query); const url = `${provider.authURL}?${new URLSearchParams(query)}`; - const width = clamp(screen.availWidth - 100, 400, 800); - const height = clamp(screen.availHeight - 100, 200, 800); - const wnd = !alwaysUseTab && await browserWindows.getLastFocused(); - const finalUrl = await launchWebAuthFlow({ - url, - alwaysUseTab, - interactive, - redirect_uri: query.redirect_uri, - windowOptions: wnd && Object.assign({ - state: 'normal', - width, - height, - }, wnd.state !== 'minimized' && { - // Center the popup to the current window - top: Math.ceil(wnd.top + (wnd.height - width) / 2), - left: Math.ceil(wnd.left + (wnd.width - width) / 2), - }), - }); + const finalUrl = await (process.env.MV3 ? authUserMV3 : authUserMV2)(url, interactive, + customRedirectUri); const params = new URLSearchParams( provider.flow === 'token' ? new URL(finalUrl).hash.slice(1) : @@ -206,6 +189,53 @@ async function authUser(keys, name, interactive = false, hooks = null) { return handleTokenResult(result, keys); } +async function authUserMV2(url, interactive, redirectUri) { + alwaysUseTab ??= await isVivaldi; + const width = clamp(screen.availWidth - 100, 400, 800); + const height = clamp(screen.availHeight - 100, 200, 800); + const wnd = !alwaysUseTab && await browserWindows.getLastFocused(); + return launchWebAuthFlow({ + url, + alwaysUseTab, + interactive, + redirect_uri: redirectUri || DEFAULT_REDIRECT_URI, + windowOptions: wnd && Object.assign({ + state: 'normal', + width, + height, + }, wnd.state !== 'minimized' && { + // Center the popup to the current window + top: Math.ceil(wnd.top + (wnd.height - width) / 2), + left: Math.ceil(wnd.left + (wnd.width - width) / 2), + }), + }); +} + +async function authUserMV3(url, interactive, redirectUri) { + if (redirectUri) { + await updateDNR([{ + id: DNR_ID_IDENTITY, + condition: { + urlFilter: '|' + redirectUri, + resourceTypes: ['main_frame'], + }, + action: { + type: 'redirect', + redirect: { + transform: { + host: DEFAULT_REDIRECT_URI.split('/')[2], + }, + }, + }, + }]); + } + try { + return await chrome.identity.launchWebAuthFlow({interactive, url}); + } finally { + await updateDNR(null, [DNR_ID_IDENTITY]); + } +} + async function handleTokenResult(result, k) { await chromeLocal.set({ [k.TOKEN]: result.access_token, @@ -220,9 +250,7 @@ async function handleTokenResult(result, k) { async function postQuery(url, body) { const options = { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: {[kContentType]: kAppUrlencoded}, body: body ? new URLSearchParams(body) : null, }; const r = await fetch(url, options); @@ -234,25 +262,3 @@ async function postQuery(url, body) { err.code = r.status; throw err; } - -async function detectVivaldiWebRequestBug() { - // Workaround for https://github.com/openstyles/stylus/issues/1182 - if (!(isVivaldi.then ? await isVivaldi : isVivaldi)) { - return false; - } - let bugged = true; - const TEST_URL = chrome.runtime.getURL('manifest.json'); - const check = ({url}) => { - bugged = url !== TEST_URL; - }; - chrome.webRequest.onBeforeRequest.addListener(check, {urls: [TEST_URL], types: ['main_frame']}); - const {tabs: [tab]} = await browserWindows.create({ - type: 'popup', - state: 'minimized', - url: TEST_URL, - }); - await waitForTabUrl(tab.id); - browserWindows.remove(tab.windowId); - chrome.webRequest.onBeforeRequest.removeListener(check); - return bugged; -} diff --git a/src/background/usercss-install-helper.js b/src/background/usercss-install-helper.js index d1190bc9b1..12db933413 100644 --- a/src/background/usercss-install-helper.js +++ b/src/background/usercss-install-helper.js @@ -1,5 +1,6 @@ import browser from '/js/browser'; -import {DNR_ID_INSTALLER} from '/js/dnr'; +import {kContentType} from '/js/consts'; +import {DNR_ID_INSTALLER, updateDNR} from '/js/dnr'; import * as prefs from '/js/prefs'; import {FIREFOX, RX_META, URLS} from '/js/toolbox'; import {bgReady} from './common'; @@ -22,7 +23,9 @@ export function getInstallCode(url) { } function toggle(key, val) { - if (!process.env.MV3) chrome.webRequest.onHeadersReceived.removeListener(maybeInstallByMime); + if (!process.env.MV3) { + chrome.webRequest.onHeadersReceived.removeListener(maybeInstallByMime); + } tabMan.onOff(maybeInstall, val); const urls = val ? [''] : [ /* Known distribution sites where we ignore urlInstaller option, because @@ -33,9 +36,7 @@ function toggle(key, val) { ...['greasy', 'sleazy'].map(h => `https://update.${h}fork.org/`), ]; if (process.env.MV3) { - const header = 'content-type'; - /** @type {chrome.declarativeNetRequest.Rule[]} */ - const rules = [{ + updateDNR([{ id: DNR_ID_INSTALLER, condition: { regexFilter: val @@ -45,8 +46,8 @@ function toggle(key, val) { ? undefined : [...new Set(urls.map(u => u.split('/')[2]))], resourceTypes: ['main_frame'], - responseHeaders: [{header, values: ['text/*']}], - excludedResponseHeaders: [{header, values: ['text/html']}], + responseHeaders: [{header: kContentType, values: ['text/*']}], + excludedResponseHeaders: [{header: kContentType, values: ['text/html']}], }, action: { type: 'redirect', @@ -54,11 +55,7 @@ function toggle(key, val) { regexSubstitution: chrome.runtime.getURL(URLS.installUsercss + '#\\0'), }, }, - }]; - chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: rules.map(r => r.id), - addRules: rules, - }); + }]); } else { chrome.webRequest.onHeadersReceived.addListener(maybeInstallByMime, { urls: urls.reduce(reduceUsercssGlobs, []), @@ -116,7 +113,7 @@ async function maybeInstall({tabId, url, oldUrl = ''}) { } function maybeInstallByMime({tabId, url, responseHeaders}) { - const h = responseHeaders.find(_ => _.name.toLowerCase() === 'content-type'); + const h = responseHeaders.find(_ => _.name.toLowerCase() === kContentType); const isText = h && isContentTypeText(h.value); tabMan.set(tabId, isContentTypeText.name, isText); if (isText) { diff --git a/src/background/usw-api.js b/src/background/usw-api.js index 8111bd3db5..d3d7674b0f 100644 --- a/src/background/usw-api.js +++ b/src/background/usw-api.js @@ -1,3 +1,4 @@ +import {kAppJson, kContentType} from '/js/consts'; import {API} from '/js/msg'; import {deepEqual, mapObj, RX_META, tryURL, UCD, URLS} from '/js/toolbox'; import {broadcastExtension} from './broadcast'; @@ -89,7 +90,7 @@ export async function publish(id, code, usw) { if (!usw || !usw.token || !usw.id) usw = await linkStyle(style, code); const res = await uswFetch(`style/${usw.id}`, usw.token, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: {[kContentType]: kAppJson}, body: JSON.stringify({code}), }); if (!deepEqual(usw, style._usw)) { diff --git a/src/content/apply.js b/src/content/apply.js index 6aeb2f1879..3ac4859b5a 100644 --- a/src/content/apply.js +++ b/src/content/apply.js @@ -1,6 +1,6 @@ // WARNING: make sure toolbox.js runs first and sets deepCopy import * as msg from '/js/msg-base'; -import {API, apiPortDisconnect} from '/js/msg-base'; +import {API, apiPortDisconnect} from '/js/msg-api'; import * as styleInjector from './style-injector'; let isTab = process.env.PAGE || location.pathname !== '/popup.html'; diff --git a/src/edit/autocomplete.js b/src/edit/autocomplete.js index 96f27bb1d9..32e1362dba 100644 --- a/src/edit/autocomplete.js +++ b/src/edit/autocomplete.js @@ -1,10 +1,10 @@ /** Registers 'hint' helper and 'autocompleteOnTyping' option in CodeMirror */ -import {worker} from '/edit/linter/store'; import * as prefs from '/js/prefs'; import {debounce, hasOwn, stringAsRegExpStr, tryRegExp, UCD} from '/js/toolbox'; import CodeMirror from 'codemirror'; import cmFactory from './codemirror-factory'; import editor from './editor'; +import {worker} from './util'; const USO_VAR = 'uso-variable'; const USO_VALID_VAR = 'variable-3 ' + USO_VAR; diff --git a/src/edit/linter/dialogs.js b/src/edit/linter/dialogs.js index 74447ef351..8999b67476 100644 --- a/src/edit/linter/dialogs.js +++ b/src/edit/linter/dialogs.js @@ -1,10 +1,10 @@ -import {worker} from '/edit/linter/store'; +import {kAppJson} from '/js/consts'; import {$, $create, $createLink, messageBox} from '/js/dom'; import {t} from '/js/localization'; import {chromeSync, LZ_KEY} from '/js/storage-util'; import {tryJSONparse} from '/js/toolbox'; import editor from '../editor'; -import {helpPopup, showCodeMirrorPopup} from '../util'; +import {helpPopup, showCodeMirrorPopup, worker} from '../util'; import {DEFAULTS} from './defaults'; import {getIssues} from './reports'; @@ -59,7 +59,7 @@ export async function showLintConfig() { extraKeys: {'Ctrl-Enter': onConfigSave}, hintOptions: {hint}, lint: true, - mode: 'application/json', + mode: kAppJson, value: config ? stringifyConfig(config) : defaultConfig[linter], }); popup._contents.appendChild( diff --git a/src/edit/linter/engines.js b/src/edit/linter/engines.js index fc8afc762f..80ac0e4605 100644 --- a/src/edit/linter/engines.js +++ b/src/edit/linter/engines.js @@ -1,9 +1,9 @@ -import {worker} from '/edit/linter/store'; import * as prefs from '/js/prefs'; import {chromeSync, LZ_KEY} from '/js/storage-util'; import {onStorageChanged} from '/js/toolbox'; import * as linterMan from '.'; import editor from '../editor'; +import {worker} from '../util'; import {DEFAULTS} from './defaults'; const configs = new Map(); diff --git a/src/edit/linter/store.js b/src/edit/linter/store.js index 6faed048a2..207c787ca9 100644 --- a/src/edit/linter/store.js +++ b/src/edit/linter/store.js @@ -1,9 +1,4 @@ -import {createPortProxy} from '/js/port'; -import {workerPath} from '/js/urls'; - export const cms = new Map(); export const linters = []; export const lintingUpdatedListeners = []; export const unhookListeners = []; -/** @type {EditorWorker} */ -export const worker = createPortProxy(workerPath); diff --git a/src/edit/source-editor.js b/src/edit/source-editor.js index 0ae9b6831c..3bab472172 100644 --- a/src/edit/source-editor.js +++ b/src/edit/source-editor.js @@ -1,4 +1,3 @@ -import {worker} from '/edit/linter/store'; import {$, $$remove, $create, $createLink, $isTextInput, messageBox} from '/js/dom'; import {t} from '/js/localization'; import {API} from '/js/msg'; @@ -12,6 +11,7 @@ import editor, {failRegexp} from './editor'; import * as linterMan from './linter'; import MozSectionFinder from './moz-section-finder'; import MozSectionWidget from './moz-section-widget'; +import {worker} from './util'; export default function SourceEditor() { const {style, /** @type DirtyReporter */dirty} = editor; diff --git a/src/edit/util.js b/src/edit/util.js index 4a10b4c593..d52416bb62 100644 --- a/src/edit/util.js +++ b/src/edit/util.js @@ -1,8 +1,10 @@ import {CodeMirror, extraKeys, THEME_KEY} from '/cm'; import {$, $$, $create, getEventKeyName, messageBox, moveFocus} from '/js/dom'; import {t, tHTML} from '/js/localization'; +import {createPortProxy} from '/js/port'; import * as prefs from '/js/prefs'; import {clipString} from '/js/toolbox'; +import {workerPath} from '/js/urls'; import editor from './editor'; export const helpPopup = { @@ -103,6 +105,9 @@ export const rerouteHotkeys = { }, }; +/** @type {EditorWorker} */ +export const worker = createPortProxy(workerPath); + export function createHotkeyInput(prefId, {buttons = true, onDone}) { const RX_ERR = new RegExp('^(' + [ /Space/, diff --git a/src/js/consts.js b/src/js/consts.js new file mode 100644 index 0000000000..3ff1b479e5 --- /dev/null +++ b/src/js/consts.js @@ -0,0 +1,4 @@ +export const kContentType = 'content-type'; // must be lowercase! +export const kAppUrlencoded = 'application/x-www-form-urlencoded'; +export const kAppJson = 'application/json'; +export const kGetAccessToken = 'getAccessToken'; diff --git a/src/js/dnr.js b/src/js/dnr.js index 2b6a3dcb65..54de586b21 100644 --- a/src/js/dnr.js +++ b/src/js/dnr.js @@ -1 +1,12 @@ -export const DNR_ID_INSTALLER = 100; +export const DNR_ID_IDENTITY = 10; +export const DNR_ID_INSTALLER = 20; + +/** + * @param {chrome.declarativeNetRequest.Rule[]} addRules + * @param {number[]} [removeRuleIds] + * @return {Promise} + */ +export const updateDNR = ( + addRules, + removeRuleIds = addRules.map(r => r.id), +) => browser.declarativeNetRequest.updateDynamicRules({addRules, removeRuleIds}); diff --git a/src/js/msg-api.js b/src/js/msg-api.js new file mode 100644 index 0000000000..eb0a45f50a --- /dev/null +++ b/src/js/msg-api.js @@ -0,0 +1,69 @@ +export const isBg = process.env.PAGE === 'sw' + || process.env.PAGE && location.pathname.startsWith(`/${process.env.PAGE_BG}`); +export const rxIgnorableError = /Receiving end does not exist|The message port closed|moved into back\/forward cache/; +export const saveStack = () => new Error(); // Saving callstack prior to `await` +const portReqs = {}; + +export const apiHandler = !isBg && { + get: ({name: path}, name) => new Proxy( + Object.defineProperty(() => {}, 'name', {value: path ? path + '.' + name : name}), + apiHandler), + apply: apiSendProxy, +}; +export const API = isBg + ? process.env.API + : process.env.API = new Proxy({path: ''}, apiHandler); + +export let bgReadySignal; +let bgReadying = !process.env.MV3 && new Promise(fn => (bgReadySignal = fn)); +let msgId = 0; +/** @type {chrome.runtime.Port} */ +export let port; + +async function apiSend(data) { + const id = ++msgId; + const err = saveStack(); + if (!port) { + port = chrome.runtime.connect({name: 'api'}); + port.onMessage.addListener(apiPortResponse); + port.onDisconnect.addListener(apiPortDisconnect); + } + port.postMessage({id, data, TDM: self.TDM}); + return new Promise((ok, ko) => (portReqs[id] = {ok, ko, err})); +} + +export function apiSendProxy({name: path}, thisObj, args) { + return (bgReadying ? sendRetry : apiSend)({method: 'invokeAPI', path, args}); +} + +export function apiPortDisconnect() { + const error = chrome.runtime.lastError; + if (error) for (const id in portReqs) apiPortResponse({id, error}); + port = null; +} + +function apiPortResponse({id, data, error}) { + const req = portReqs[id]; + delete portReqs[id]; + if (error) { + const {err} = req; + err.message = error.message; + if (error.stack) err.stack = error.stack + '\n' + err.stack; + req.ko(error); + } else { + req.ok(data); + } +} + +async function sendRetry(m) { + try { + return await apiSend(m); + } catch (e) { + return bgReadying && rxIgnorableError.test(e.message) + ? await bgReadying && apiSend(m) + : Promise.reject(e); + } finally { + // Assuming bg is ready if messaging succeeded + bgReadying = bgReadySignal = null; + } +} diff --git a/src/js/msg-base.js b/src/js/msg-base.js index af360c680b..e3dcb1458b 100644 --- a/src/js/msg-base.js +++ b/src/js/msg-base.js @@ -1,5 +1,7 @@ -export const isBg = process.env.PAGE === 'sw' - || process.env.PAGE && location.pathname.startsWith(`/${process.env.PAGE_BG}`); +import {apiPortDisconnect, bgReadySignal, port, rxIgnorableError, saveStack} from './msg-api'; + +export * from './msg-api'; + const TARGETS = { __proto: null, all: ['both', 'tab', 'extension'], @@ -11,27 +13,6 @@ const handler = { tab: new Set(), extension: new Set(), }; -const rxIgnorableError = /Receiving end does not exist|The message port closed|moved into back\/forward cache/; -const saveStack = () => new Error(); // Saving callstack prior to `await` -const portReqs = {}; - -export const apiHandler = !isBg && { - get: ({name: path}, name) => new Proxy( - Object.defineProperty(() => {}, 'name', {value: path ? path + '.' + name : name}), - apiHandler), - apply: apiSendProxy, -}; -/** @type {API} */ -export const API = isBg - ? global.API - : global.API = new Proxy({path: ''}, apiHandler); - -let bgReadySignal; -let bgReadying = !process.env.MV3 && new Promise(fn => (bgReadySignal = fn)); -let msgId = 0; -/** @type {chrome.runtime.Port} */ -let port; - // TODO: maybe move into browser.js and hook addListener to wrap/unwrap automatically chrome.runtime.onMessage.addListener(onRuntimeMessage); @@ -71,41 +52,6 @@ export function _execute(target, ...args) { return result; } -async function apiSend(data) { - const id = ++msgId; - const err = saveStack(); - if (!port) { - port = chrome.runtime.connect({name: 'api'}); - port.onMessage.addListener(apiPortResponse); - port.onDisconnect.addListener(apiPortDisconnect); - } - port.postMessage({id, data, TDM: self.TDM}); - return new Promise((ok, ko) => (portReqs[id] = {ok, ko, err})); -} - -export function apiSendProxy({name: path}, thisObj, args) { - return (bgReadying ? sendRetry : apiSend)({method: 'invokeAPI', path, args}); -} - -export function apiPortDisconnect() { - const error = chrome.runtime.lastError; - if (error) for (const id in portReqs) apiPortResponse({id, error}); - port = null; -} - -function apiPortResponse({id, data, error}) { - const req = portReqs[id]; - delete portReqs[id]; - if (error) { - const {err} = req; - err.message = error.message; - if (error.stack) err.stack = error.stack + '\n' + err.stack; - req.ko(error); - } else { - req.ok(data); - } -} - export function onRuntimeMessage({data, target}, sender, sendResponse) { if (data.method === 'backgroundReady') { if (bgReadySignal) bgReadySignal(true); @@ -146,16 +92,3 @@ export function wrapError(error) { }, error), // passing custom properties e.g. `error.index` }; } - -async function sendRetry(m) { - try { - return await apiSend(m); - } catch (e) { - return bgReadying && rxIgnorableError.test(e.message) - ? await bgReadying && apiSend(m) - : Promise.reject(e); - } finally { - // Assuming bg is ready if messaging succeeded - bgReadying = bgReadySignal = null; - } -} diff --git a/src/js/msg.js b/src/js/msg.js index 8e0e29d81b..e5fcc21f4d 100644 --- a/src/js/msg.js +++ b/src/js/msg.js @@ -3,6 +3,7 @@ import browser from './browser'; import {apiHandler, apiSendProxy, isBg, unwrap} from './msg-base'; import {createPortExec, createPortProxy} from './port'; import {deepCopy, getOwnTab, URLS} from './toolbox'; +import {swPath} from './urls'; export * from './msg-base'; @@ -12,7 +13,7 @@ const needsTab = [ ]; /** @type {MessagePort} */ const swExec = process.env.MV3 && - createPortExec(() => navigator.serviceWorker.controller, `/${process.env.PAGE_BG}.js`); + createPortExec(() => navigator.serviceWorker.controller, swPath); const workerApiPrefix = 'worker.'; export let bg = isBg ? self : !process.env.MV3 && chrome.extension.getBackgroundPage(); let bgWorkerProxy; diff --git a/src/js/port.js b/src/js/port.js index 3d29b59f6c..d84ff83e80 100644 --- a/src/js/port.js +++ b/src/js/port.js @@ -1,12 +1,4 @@ export const PORT_TIMEOUT = 5 * 60e3; // TODO: expose as a configurable option -const ERROR_PROPS_TO_CLONE = [ - 'name', - 'stack', - 'message', - 'lineNumber', - 'columnNumber', - 'fileName', -]; const ret0 = () => 0; let timer; let lockingSelf; @@ -15,7 +7,9 @@ export function createPortProxy(getTarget, lockName) { let exec; const init = (...args) => (exec ??= createPortExec(getTarget, lockName))(...args); return new Proxy({}, { - get: (_, cmd) => (exec || init)?.bind(null, cmd), + get: (_, cmd) => function (...args) { + return (exec || init).call(this, cmd, ...args); + }, }); } @@ -113,9 +107,9 @@ export function initRemotePort(evt, exec, autoClose) { if (res instanceof Promise) res = await res; } catch (e) { res = undefined; // clearing a rejected Promise - err = {}; - for (const p of ERROR_PROPS_TO_CLONE) err[p] = e[p]; - Object.assign(err, e); + err = e; + delete e.source; + // TODO: find which props are actually used (err may contain noncloneable Response) } port.postMessage({id, res, err}, (/**@type{RemotePortEvent}*/portEvent)._transfer); if (!--numJobs && autoClose) closeAfterDelay(); diff --git a/src/js/urls.js b/src/js/urls.js index 0aea5c7659..a32f2b9d0b 100644 --- a/src/js/urls.js +++ b/src/js/urls.js @@ -4,6 +4,7 @@ import {CHROME} from './ua'; export const ownRoot = /*@__PURE__*/ chrome.runtime.getURL(''); export const installUsercss = 'install-usercss.html'; export const workerPath = '/js/worker.js'; +export const swPath = `/${process.env.PAGE_BG}.js`; export const favicon = host => `https://icons.duckduckgo.com/ip3/${host}.ico`; /** Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/ */ export const chromeProtectsNTP = process.env.MV3 || CHROME >= 61; diff --git a/src/js/util-base.js b/src/js/util-base.js index 4370ff1dc0..d80b0c00a8 100644 --- a/src/js/util-base.js +++ b/src/js/util-base.js @@ -119,3 +119,15 @@ export function deepEqual(a, b, ignoredKeys) { export async function fetchText(url, opts) { return (await fetch(url, opts)).text(); } + +/** @this {Object} DriveOptions */ +export function fetchWebDAV(url, init = {}) { + return fetch(url, { + ...init, + credentials: 'omit', // circumventing nextcloud CSRF token error + headers: { + ...init.headers, + Authorization: `Basic ${btoa(`${this.username || ''}:${this.password || ''}`)}`, + }, + }); +} diff --git a/src/manage/import-export.js b/src/manage/import-export.js index eaf45d18f0..5ee31c2e2e 100644 --- a/src/manage/import-export.js +++ b/src/manage/import-export.js @@ -1,3 +1,4 @@ +import {kAppJson} from '/js/consts'; import {$, $$, $create, animateElement, messageBox, scrollElementIntoView} from '/js/dom'; import {t} from '/js/localization'; import {API} from '/js/msg'; @@ -60,7 +61,7 @@ async function importFromFile(file) { } else { el.style.display = 'none'; el.type = 'file'; - el.accept = 'application/json' + (MOBILE ? ',text/plain'/*for GDrive-like apps*/ : ''); + el.accept = kAppJson + (MOBILE ? ',text/plain'/*for GDrive-like apps*/ : ''); el.acceptCharset = 'utf-8'; document.body.appendChild(el); el.initialValue = el.value; @@ -362,7 +363,7 @@ async function exportToFile(e) { ...(await API.styles.getAll()).map(cleanupStyle), ]; const text = JSON.stringify(data, null, ' '); - const type = 'application/json'; + const type = kAppJson; $create('a', { href: URL.createObjectURL(new Blob([text], {type})), download: generateFileName(), diff --git a/src/offscreen/index.js b/src/offscreen/index.js index dba6167204..adc6251460 100644 --- a/src/offscreen/index.js +++ b/src/offscreen/index.js @@ -1,9 +1,22 @@ +import {kGetAccessToken} from '/js/consts'; +import {API} from '/js/msg-api'; import {initRemotePort} from '/js/port'; -import {isCssDarkScheme} from '/js/util-base'; +import {fetchWebDAV, isCssDarkScheme} from '/js/util-base'; +import createWebDAV from 'db-to-cloud/lib/drive/webdav'; + +let webdav; /** @namespace OffscreenAPI */ const COMMANDS = { __proto__: null, + webdavInit: cfg => { + cfg.fetch = fetchWebDAV.bind(cfg); + cfg[kGetAccessToken] = API.sync[kGetAccessToken]; + webdav = createWebDAV(cfg); + for (const k in webdav) if (typeof webdav[k] === 'function') webdav[k] = null; + return webdav; + }, + webdav: (cmd, ...args) => webdav[cmd](...args), /** Note that `onchange` doesn't work in bg context, so we use it in the content script */ isDark: isCssDarkScheme, /** @this {RemotePortEvent} */ diff --git a/src/options/options-sync.js b/src/options/options-sync.js index 3773483d68..223c0fb1aa 100644 --- a/src/options/options-sync.js +++ b/src/options/options-sync.js @@ -13,7 +13,8 @@ import {capitalize} from '/js/toolbox'; const elSyncNow = $('.sync-now', elSync); const elStatus = $('.sync-status', elSync); const elLogin = $('.sync-login', elSync); - const elDriveOptions = $$('.drive-options', template.body); + const elDriveOptions = $$('.drive-options', elSync); + const $$driveOptions = () => $$(`[data-drive=${elCloud.value}] [data-option]`, elSync); updateButtons(); onExtension(e => { if (e.method === 'syncStatusUpdate') { @@ -37,14 +38,14 @@ import {capitalize} from '/js/toolbox'; function getDriveOptions() { const result = {}; - for (const el of $$(`[data-drive=${elCloud.value}] [data-option]`)) { + for (const el of $$driveOptions()) { result[el.dataset.option] = el.value; } return result; } function setDriveOptions(options) { - for (const el of $$(`[data-drive=${elCloud.value}] [data-option]`)) { + for (const el of $$driveOptions()) { el.value = options[el.dataset.option] || ''; } } @@ -73,9 +74,8 @@ import {capitalize} from '/js/toolbox'; el.disabled = !off; } toggleDataset(elSync, 'enabled', elCloud.value !== 'none'); - setDriveOptions(process.env.MV3 - ? global.clientData.syncOpts - : await API.sync.getDriveOptions(elCloud.value)); + setDriveOptions(process.env.MV3 && global.clientData.syncOpts + || await API.sync.getDriveOptions(elCloud.value)); } function getStatusText() { diff --git a/src/options/options.css b/src/options/options.css index 8db678498c..47cbf83711 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -204,6 +204,7 @@ a.icon { .sync-status { padding-left: 1em; box-sizing: border-box; + overflow-wrap: break-word; &::first-letter { text-transform: uppercase; } diff --git a/tools/util.js b/tools/util.js index fad323cdbe..5619b0d499 100644 --- a/tools/util.js +++ b/tools/util.js @@ -25,11 +25,10 @@ function anyPathSep(str) { return str.replace(/[\\/]/g, /[\\/]/.source); } -function defineVars(vars) { +function defineVars(vars, raws = {}) { const env = {}; - for (const k in vars) { - env['process.env.' + k] = JSON.stringify(vars[k]); - } + for (const k in vars) env['process.env.' + k] = JSON.stringify(vars[k]); + for (const k in raws) env['process.env.' + k] = raws[k]; return new webpack.DefinePlugin(env); } diff --git a/webpack.config.js b/webpack.config.js index 7c1afde3a9..d411f3d674 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,7 +9,8 @@ const TerserPlugin = require('terser-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); -const {defineVars, stripSourceMap, MANIFEST, MANIFEST_MV3, ROOT} = require('./tools/util'); +const {anyPathSep, defineVars, stripSourceMap, MANIFEST, MANIFEST_MV3, ROOT} = + require('./tools/util'); const WebpackPatchBootstrapPlugin = require('./tools/webpack-patch-bootstrap'); const [BUILD, FLAVOR] = process.env.NODE_ENV?.split('-') || []; @@ -85,11 +86,6 @@ const CFG = { test: /\.m?js$/, options: {root: ROOT}, resolve: {fullySpecified: false}, - }, { - loader: SHIM + 'null-loader.js', - test: [ - require.resolve('db-to-cloud/lib/drive/fs-drive'), - ], }, { loader: SHIM + 'cjs-to-esm-loader.js', test: [ @@ -157,6 +153,8 @@ const CFG = { JS, MV3, PAGE_BG, + }, { + API: 'global.API', // hiding `global` from IDE so it doesn't see `API` as a global }), new WebpackPatchBootstrapPlugin(), ], @@ -255,23 +253,23 @@ module.exports = [ optimization: { splitChunks: { chunks: 'all', - // cacheGroups: { - // codemirror: { - // test: new RegExp(String.raw`(${anyPathSep([ - // SRC + 'cm/', - // String.raw`codemirror(/|-(?!factory))`, - // ].join('|'))}).+\.js$`), - // name: 'codemirror', - // }, - // ...Object.fromEntries([ - // [2, 'common-ui', `^${SRC}(content/|js/(dom|localization|themer))`], - // [1, 'common', `^${SRC}js/|/lz-string(-unsafe)?/`], - // ].map(([priority, name, test]) => [name, { - // test: new RegExp(String.raw`(${anyPathSep(test)})[^./\\]*\.js$`), - // name, - // priority, - // }])), - // }, + cacheGroups: { + codemirror: { + test: new RegExp(String.raw`(${anyPathSep([ + SRC + 'cm/', + String.raw`codemirror(/|-(?!factory))`, + ].join('|'))}).+\.js$`), + name: 'codemirror', + }, + ...Object.fromEntries([ + [2, 'common-ui', `^${SRC}(content/|js/(dom|localization|themer))`], + [1, 'common', `^${SRC}js/|/lz-string(-unsafe)?/`], + ].map(([priority, name, test]) => [name, { + test: new RegExp(String.raw`(${anyPathSep(test)})[^./\\]*\.js$`), + name, + priority, + }])), + }, }, }, plugins: [