uBlock/platform/mv3/extension/js/develop.js
2025-05-30 11:23:45 -04:00

709 lines
24 KiB
JavaScript

/*******************************************************************************
uBlock Origin Lite - a comprehensive, MV3-compliant content blocker
Copyright (C) 2014-present Raymond Hill
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see {http://www.gnu.org/licenses/}.
Home: https://github.com/gorhill/uBlock
*/
import {
browser,
localRead,
localRemove,
localWrite,
sendMessage,
} from './ext.js';
import { dom, qs$ } from './dom.js';
import { rulesFromText, textFromRules } from './dnr-parser.js';
import { dnr } from './ext-compat.js';
import { i18n$ } from './i18n.js';
/******************************************************************************/
// Details of YAML document(s) intersecting with a text span. If the text span
// starts on a YAML document divider, the previous YAML document will be
// included. If the text span ends on a YAML document divider, the next YAML
// document will be included.
function snapToYamlDocument(doc, start, end) {
let yamlDocStart = doc.lineAt(start).number;
if ( reYamlDocSeparator.test(doc.line(yamlDocStart).text) ) {
if ( yamlDocStart > 1 ) {
yamlDocStart -= 1;
}
}
while ( yamlDocStart > 1 ) {
const line = doc.line(yamlDocStart);
if ( reYamlDocSeparator.test(line.text) ) { break; }
yamlDocStart -= 1;
}
const lastLine = doc.lines;
let yamlDocEnd = doc.lineAt(end).number;
if ( reYamlDocSeparator.test(doc.line(yamlDocEnd).text) ) {
if ( yamlDocEnd < lastLine ) {
yamlDocEnd += 1;
}
}
while ( yamlDocEnd < lastLine ) {
const line = doc.line(yamlDocEnd);
if ( reYamlDocSeparator.test(line.text) ) { break; }
yamlDocEnd += 1;
}
return { yamlDocStart, yamlDocEnd };
}
function rangeFromTransaction(transaction) {
let from, to;
transaction.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
if ( from === undefined || fromB < from ) { from = fromB; }
if ( to === undefined || toB > to ) { to = toB; }
});
return { from, to };
}
function addToModifiedRange(transaction) {
const { newDoc } = transaction;
const { from, to } = rangeFromTransaction(transaction);
if ( from === undefined || to === undefined ) { return; }
const { yamlDocStart, yamlDocEnd } = snapToYamlDocument(newDoc, from, to);
if ( modifiedRange.start === -1 || yamlDocStart < modifiedRange.start ) {
modifiedRange.start = yamlDocStart;
}
if ( modifiedRange.end === -1 || yamlDocEnd > modifiedRange.end ) {
modifiedRange.end = yamlDocEnd;
}
}
const reYamlDocSeparator = /^(?:---|...)\s*$/;
const modifiedRange = { start: -1, end: -1 };
/******************************************************************************/
function rulesFromJSON(json) {
let content = json.trim();
if ( /^[[{]/.test(content) === false ) {
const match = /^[^[{]+/.exec(content);
if ( match === null ) { return; }
content = content.slice(match[0].length);
}
const firstChar = content.charAt(0);
const expectedLastChar = firstChar === '[' ? ']' : '}';
if ( content.at(-1) !== expectedLastChar ) {
const re = new RegExp(`\\${expectedLastChar}[^\\${expectedLastChar}]+$`);
const match = re.exec(content);
if ( match === null ) { return; }
content = content.slice(0, match.index+1);
}
if ( content.startsWith('{') && content.endsWith('}') ) {
content = `[${content}]`;
}
try {
const rules = JSON.parse(content);
if ( Array.isArray(rules) ) { return rules; }
}
catch {
}
}
/******************************************************************************/
function lineIndentAt(line) {
const match = /^(?: {2})*/.exec(line.text);
const indent = match !== null ? match[0].length : -1;
if ( indent === -1 || (indent & 1) !== 0 ) { return -1; }
return indent / 2;
}
function getScopeAt(from) {
const { doc } = cmRules.state;
const lineFrom = doc.lineAt(from);
let depth = lineIndentAt(lineFrom);
if ( depth === -1 ) { return; }
const text = lineFrom.text.trim();
if ( text.startsWith('#') ) { return; }
const path = [];
const pos = text.indexOf(':');
if ( pos !== -1 ) {
path.push(text.slice(0, pos+1));
}
let lineNo = lineFrom.number;
while ( depth > 0 && lineNo > 1 ) {
lineNo -= 1;
const lineBefore = doc.line(lineNo);
const text = lineBefore.text.trim();
if ( text.startsWith('#') ) { continue; }
if ( lineIndentAt(lineBefore) > (depth-1) ) { continue; }
const match = /^- ([^:]+:)/.exec(text);
if ( match !== null ) {
path.unshift(match[1]);
} else {
path.unshift(text);
}
depth -= 1;
}
return path.join('');
}
function getAutocompleteCandidates(from) {
const scope = getScopeAt(from);
switch ( scope ) {
case '':
return {
before: /^$/,
candidates: [
{ token: 'action:', after: '\n ' },
{ token: 'condition:', after: '\n ' },
{ token: 'priority:', after: ' ' },
{ token: '---', after: '\n' },
]
};
case 'action:':
return {
before: /^ {2}$/,
candidates: [
{ token: 'type:', after: ' ' },
{ token: 'redirect:', after: '\n ' },
{ token: 'requestHeaders:', after: '\n - header: ' },
{ token: 'responseHeaders:', after: '\n - header: ' },
],
};
case 'action:type:':
return {
before: /: $/,
candidates: [
{ token: 'block', after: '\n ' },
{ token: 'redirect', after: '\n ' },
{ token: 'allow', after: '\n ' },
{ token: 'modifyHeaders', after: '\n ' },
{ token: 'upgradeScheme', after: '\n ' },
{ token: 'allowAllRequest', after: '\n ' },
],
};
case 'action:redirect:':
return {
before: /^ {4}$/,
candidates: [
{ token: 'extensionPath:', after: ' ' },
{ token: 'regexSubstitution:', after: ' ' },
{ token: 'transform:', after: '\n ' },
{ token: 'url:', after: ' ' },
],
};
case 'action:redirect:transform:':
return {
before: /^ {6}$/,
candidates: [
{ token: 'fragment:', after: ' ' },
{ token: 'host:', after: ' ' },
{ token: 'path:', after: ' ' },
{ token: 'port:', after: ' ' },
{ token: 'query:', after: ' ' },
{ token: 'scheme:', after: ' ' },
{ token: 'queryTransform:', after: '\n ' },
],
};
case 'action:redirect:transform:queryTransform:':
return {
before: /^ {8}$/,
candidates: [
{ token: 'addOrReplaceParams:', after: '\n - ' },
{ token: 'removeParams:', after: '\n - ' },
],
};
case 'action:responseHeaders:':
return {
before: /^ {4}- $/,
candidates: [
{ token: 'header:', after: ' ' },
],
};
case 'action:responseHeaders:header:':
return {
before: /^ {6}$/,
candidates: [
{ token: 'operation:', after: ' ' },
{ token: 'value:', after: ' ' },
],
};
case 'action:responseHeaders:header:operation:':
return {
before: /: $/,
candidates: [
{ token: 'append', after: '\n value: ' },
{ token: 'set', after: '\n value: ' },
{ token: 'remove', after: '\n ' },
],
};
case 'condition:':
return {
before: /^ {2}$/,
candidates: [
{ token: 'domainType:', after: ' ' },
{ token: 'isUrlFilterCaseSensitive:', after: ' ' },
{ token: 'regexFilter:', after: ' ' },
{ token: 'urlFilter:', after: ' ' },
{ token: 'initiatorDomains:', after: '\n - ' },
{ token: 'excludedInitiatorDomains:', after: '\n - ' },
{ token: 'domains:', after: '\n - ' },
{ token: 'excludedDomains:', after: '\n - ' },
{ token: 'requestDomains:', after: '\n - ' },
{ token: 'excludedRequestDomains:', after: '\n - ' },
{ token: 'resourceTypes:', after: '\n - ' },
{ token: 'excludedResourceTypes:', after: '\n - ' },
{ token: 'requestMethods:', after: '\n - ' },
{ token: 'excludedRequestMethods:', after: '\n - ' },
{ token: 'responseHeaders:', after: '\n - ' },
{ token: 'excludedResponseHeaders:', after: '\n - ' },
],
};
case 'condition:domainType:':
return {
before: /: $/,
candidates: [
{ token: 'firstParty', after: '\n ' },
{ token: 'thirdParty', after: '\n ' },
],
};
case 'condition:isUrlFilterCaseSensitive:':
return {
before: /: $/,
candidates: [
{ token: 'true', after: '\n ' },
{ token: 'false', after: '\n ' },
],
};
case 'condition:requestMethods:':
case 'condition:excludedRequestMethods:':
return {
before: /^ {4}- $/,
candidates: [
{ token: 'connect', after: '\n - ' },
{ token: 'delete', after: '\n - ' },
{ token: 'get', after: '\n - ' },
{ token: 'head', after: '\n - ' },
{ token: 'options', after: '\n - ' },
{ token: 'patch', after: '\n - ' },
{ token: 'post', after: '\n - ' },
{ token: 'put', after: '\n - ' },
{ token: 'other', after: '\n ' },
],
};
case 'condition:resourceTypes:':
case 'condition:excludedResourceTypes:':
return {
before: /^ {4}- $/,
candidates: [
{ token: 'main_frame', after: '\n - ' },
{ token: 'sub_frame', after: '\n - ' },
{ token: 'stylesheet', after: '\n - ' },
{ token: 'script', after: '\n - ' },
{ token: 'image', after: '\n - ' },
{ token: 'font', after: '\n - ' },
{ token: 'object', after: '\n - ' },
{ token: 'xmlhttprequest', after: '\n - ' },
{ token: 'ping', after: '\n - ' },
{ token: 'csp_report', after: '\n - ' },
{ token: 'media', after: '\n - ' },
{ token: 'websocket', after: '\n - ' },
{ token: 'webtransport', after: '\n - ' },
{ token: 'webbundle', after: '\n - ' },
{ token: 'other', after: '\n ' },
],
};
}
}
function autoComplete(context) {
const match = context.matchBefore(/[\w-]*/);
if ( match === undefined ) { return null; }
const result = getAutocompleteCandidates(match.from);
if ( result === undefined ) { return null; }
if ( result.before !== undefined ) {
const { doc } = context.state;
const line = doc.lineAt(context.pos);
const before = doc.sliceString(line.from, match.from);
if ( result.before.test(before) === false ) { return null; }
}
const filtered = result.candidates.filter(e =>
e.token !== match.text || e.after !== '\n'
);
return {
from: match.from,
options: filtered.map(e => ({ label: e.token, apply: `${e.token}${e.after}` })),
validFor: /\w*/,
};
}
/******************************************************************************/
function setEditorText(text) {
if ( text === undefined ) { return; }
if ( text !== '' ) { text += '\n'; }
cmRules.dispatch({
changes: {
from: 0, to: cmRules.state.doc.length,
insert: text
},
});
}
function getEditorText() {
return cmRules.state.doc.toString();
}
/******************************************************************************/
function saveEditorText() {
const text = getEditorText().trim();
const promise = text.length !== 0
? localWrite('userDnrRules', text)
: localRemove('userDnrRules');
promise.then(( ) => {
lastSavedText = text;
updateView();
}).then(( ) =>
sendMessage({ what: 'updateUserDnrRules' })
).then(result => {
if ( result instanceof Object === false ) { return; }
updateFeedbackPanel(result);
});
}
/******************************************************************************/
async function validateRegexes(regexes) {
if ( regexes.length === 0 ) { return; }
const promises = regexes.map(regex => validateRegex(regex));
await Promise.all(promises);
for ( const regex of regexes ) {
const i = validatedRegexes.regexes.indexOf(regex);
if ( i === -1 ) { continue; }
const reason = validatedRegexes.results[i];
if ( reason === true ) { continue; }
const entries = self.cm6.findAll(cmRules,
`(?<=\\bregexFilter: )${RegExp.escape(regex)}`
);
for ( const entry of entries ) {
self.cm6.spanErrorAdd(cmRules, entry.from, entry.to, reason);
}
}
}
async function validateRegex(regex) {
const details = await dnr.isRegexSupported({ regex });
const result = details.isSupported || details.reason;
if ( validatedRegexes.regexes.length > 32 ) {
validatedRegexes.regexes.pop();
validatedRegexes.results.pop();
}
validatedRegexes.regexes.unshift(regex);
validatedRegexes.results.unshift(result);
}
const validatedRegexes = {
regexes: [],
results: [],
};
/******************************************************************************/
function updateView() {
const { doc } = cmRules.state;
const changed = doc.toString().trim() !==
lastSavedText.trim();
dom.attr('#dnrRulesApply', 'disabled', changed ? null : '');
dom.attr('#dnrRulesRevert', 'disabled', changed ? null : '');
const { start, end } = modifiedRange;
if ( start === -1 || end === -1 ) { return; }
modifiedRange.start = modifiedRange.end = -1;
self.cm6.lineErrorClear(cmRules, start, end);
self.cm6.spanErrorClear(cmRules, start, end);
const firstLine = doc.line(start);
const lastLine = doc.line(end);
const text = doc.sliceString(firstLine.from, lastLine.to);
const { bad } = rulesFromText(text);
if ( Array.isArray(bad) && bad.length !== 0 ) {
self.cm6.lineErrorAdd(cmRules, bad.map(i => i + start));
}
const entries = self.cm6.findAll(
cmRules,
'\\bregexFilter: (\\S+)',
firstLine.from,
lastLine.to
);
const regexes = [];
for ( const entry of entries ) {
const regex = entry.match[1];
const i = validatedRegexes.regexes.indexOf(regex);
if ( i !== -1 ) {
const reason = validatedRegexes.results[i];
if ( reason === true ) { continue; }
self.cm6.spanErrorAdd(cmRules, entry.from+13, entry.to, reason);
} else {
regexes.push(regex);
}
}
validateRegexes(regexes);
}
function updateViewAsync() {
if ( updateViewAsync.timer !== undefined ) { return; }
updateViewAsync.timer = self.setTimeout(( ) => {
updateViewAsync.timer = undefined;
updateView();
}, 71);
}
/******************************************************************************/
function updateSummaryPanel(info) {
self.cm6.showSummaryPanel(cmRules, {
template: '.summary-panel',
text: i18n$('dnrRulesCountInfo')
.replace('{count}', (info.userDnrRuleCount || 0).toLocaleString()),
});
}
browser.storage.onChanged.addListener((changes, area) => {
if ( area !== 'local' ) { return; }
const { userDnrRuleCount } = changes;
if ( userDnrRuleCount instanceof Object === false ) { return; }
const { newValue } = changes.userDnrRuleCount;
updateSummaryPanel({ userDnrRuleCount: newValue });
});
localRead('userDnrRuleCount').then(userDnrRuleCount => {
updateSummaryPanel({ userDnrRuleCount })
});
function updateFeedbackPanel(info) {
const errors = [];
if ( Array.isArray(info.errors) ) {
info.errors.forEach(e => errors.push(e));
}
const text = errors.join('\n');
self.cm6.showFeedbackPanel(cmRules, { template: '.feedback-panel', text });
}
/******************************************************************************/
function importRulesFromFile() {
const input = qs$('input[type="file"]');
input.onchange = ev => {
input.onchange = null;
const file = ev.target.files[0];
if ( file === undefined || file.name === '' ) { return; }
if ( file.type !== 'application/json' ) { return; }
const fr = new FileReader();
fr.onload = ( ) => {
if ( typeof fr.result !== 'string' ) { return; }
const rules = rulesFromJSON(fr.result);
if ( rules === undefined ) { return; }
const text = textFromRules(rules);
if ( text === undefined ) { return; }
const { doc } = cmRules.state;
const lastChars = doc.toString().trimEnd().slice(-4);
const lastLine = doc.line(doc.lines);
let from = lastLine.to;
let prepend = '';
if ( lastLine.text !== '' ) {
prepend = '\n';
} else {
from = lastLine.from;
}
if ( /(?:^|\n)---$/.test(lastChars) === false ) {
prepend = `${prepend}---\n`;
}
cmRules.dispatch({ changes: { from, insert: `${prepend}${text}` } });
cmRules.focus();
};
fr.readAsText(file);
};
// Reset to empty string, this will ensure a change event is properly
// triggered if the user pick a file, even if it's the same as the last
// one picked.
input.value = '';
input.click();
}
dom.on('#dnrRulesImport', 'click', importRulesFromFile);
/******************************************************************************/
function exportRulesToFile() {
const text = getEditorText();
const { rules } = rulesFromText(text);
if ( Array.isArray(rules) === false ) { return; }
let ruleId = 1;
for ( const rule of rules ) {
rule.id = ruleId++;
}
const filename = 'my-ubol-dnr-rules.json';
const a = document.createElement('a');
a.href = `data:application/json;charset=utf-8,${JSON.stringify(rules, null, 2)}`;
dom.attr(a, 'download', filename || '');
dom.attr(a, 'type', 'application/json');
a.click();
}
dom.on('#dnrRulesExport', 'click', exportRulesToFile);
/******************************************************************************/
function importRulesFromPaste(transaction) {
const { from, to } = rangeFromTransaction(transaction);
if ( from === undefined || to === undefined ) { return; }
// Paste position must match start of a line
const { doc } = transaction.newDoc;
const lineFrom = doc.lineAt(from);
if ( lineFrom.from !== from ) { return; }
// Paste position must match a rule boundary
if ( lineFrom.number !== 1 ) {
const lineBefore = doc.line(lineFrom.number-1);
if ( /^---\s*$/.test(lineBefore.text) === false ) { return; }
}
const pastedText = doc.sliceString(from, to);
const rules = rulesFromJSON(pastedText);
if ( rules === undefined ) { return; }
const yamlText = textFromRules(rules);
if ( yamlText === undefined ) { return; }
cmRules.dispatch({ changes: { from, to, insert: yamlText } });
return true;
}
/******************************************************************************/
function smartBackspace(transaction) {
const { from, to } = rangeFromTransaction(transaction);
if ( from === undefined || to === undefined ) { return; }
const { newDoc } = transaction;
const line = newDoc.lineAt(from);
if ( /^(?: {2})+-$/.test(line.text) === false ) { return; }
cmRules.dispatch({ changes: { from: from-3, to: from, insert: '' } });
return true;
}
/******************************************************************************/
function cmUpdateListener(info) {
if ( info.docChanged === false ) { return; }
for ( const transaction of info.transactions ) {
if ( transaction.docChanged === false ) { continue; }
addToModifiedRange(transaction);
if ( transaction.isUserEvent('delete.backward') ) {
if ( smartBackspace(transaction) ) { break; }
} else if ( transaction.isUserEvent('input.paste') ) {
if ( importRulesFromPaste(transaction) ) { break; }
}
}
updateViewAsync();
}
/******************************************************************************/
function gutterClick(view, info) {
const reSeparator = /^---\s*/;
const { doc } = view.state;
const lineFirst = doc.lineAt(info.from);
if ( lineFirst.text === '' ) { return false; }
let { from, to } = lineFirst;
if ( reSeparator.test(lineFirst.text) ) {
let lineNo = lineFirst.number + 1;
while ( lineNo < doc.lines ) {
const line = doc.line(lineNo);
if ( reSeparator.test(line.text) ) { break; }
to = line.to;
lineNo += 1;
}
}
view.dispatch({
selection: { anchor: from, head: to+1 }
});
view.focus();
return true;
}
/******************************************************************************/
function hoverTooltip(view, pos, side) {
const details = view.domAtPos(pos);
const textNode = details.node;
if ( textNode.nodeType !== 3 ) { return null; }
const { parentElement } = textNode;
const targetElement = parentElement.closest('[data-tooltip]');
if ( targetElement === null ) { return null; }
const tooltipText = targetElement.getAttribute('data-tooltip');
if ( Boolean(tooltipText) === false ) { return null; }
const start = pos - details.offset;
const end = start + textNode.nodeValue.length;
if ( start === pos && side < 0 || end === pos && side > 0 ) { return null; }
return {
above: true,
pos: start,
end,
create() {
const template = document.querySelector('.badmark-tooltip');
const fragment = template.content.cloneNode(true);
const dom = fragment.querySelector('.badmark-tooltip');
dom.textContent = tooltipText;
return { dom };
},
};
}
/******************************************************************************/
const cmRules = (( ) => {
return self.cm6.createEditorView({
dnrRules: true,
oneDark: dom.cl.has(':root', 'dark'),
updateListener: cmUpdateListener,
saveListener: ( ) => {
saveEditorText();
},
lineError: true,
spanError: true,
// https://codemirror.net/examples/autocompletion/
autocompletion: {
override: [ autoComplete ],
activateOnCompletion: ( ) => true,
},
gutterClick,
hoverTooltip,
}, qs$('#cm-dnrRules'));
})();
/******************************************************************************/
let lastSavedText = '';
localRead('userDnrRules').then(text => {
text ||= '';
setEditorText(text);
lastSavedText = text;
self.cm6.resetUndoRedo(cmRules);
dom.on('#dnrRulesApply', 'click', ( ) => {
saveEditorText();
});
dom.on('#dnrRulesRevert', 'click', ( ) => {
setEditorText(lastSavedText);
sendMessage({ what: 'updateUserDnrRules' });
});
});
/******************************************************************************/