index.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. // SAFETY WARNING! Exports used by `injected` must make ::safe() calls and use __proto__:null
  2. import { browser, HOMEPAGE_URL, INFERRED, RUN_AT_RE, SUPPORT_URL } from './consts';
  3. import { deepCopy } from './object';
  4. import { blob2base64, i18n, isDataUri, tryUrl } from './util';
  5. export { normalizeKeys } from './object';
  6. export * from './util';
  7. if (process.env.DEV && process.env.IS_INJECTED !== 'injected-web') {
  8. const get = () => {
  9. throw 'Do not use `for-of` with Map/Set. Use forEach or for-of with a [...copy]'
  10. + '\n(not supported due to our config of @babel/plugin-transform-for-of).';
  11. };
  12. for (const obj of [Map, Set, WeakMap, WeakSet]) {
  13. Object.defineProperty(obj.prototype, 'length', { get, configurable: true });
  14. }
  15. }
  16. export const ignoreChromeErrors = () => chrome.runtime.lastError;
  17. export const browserWindows = !process.env.IS_INJECTED && browser.windows;
  18. export const defaultImage = !process.env.IS_INJECTED && `${ICON_PREFIX}128.png`;
  19. /** @return {'0' | '1' | ''} treating source as abstract truthy/falsy to ensure consistent result */
  20. export const nullBool2string = v => v ? '1' : v == null ? '' : '0';
  21. /** Will be encoded to avoid splitting the URL in devtools UI */
  22. const BAD_URL_CHAR = /[#/?]/g;
  23. /** Fullwidth range starts at 0xFF00, normal range starts at space char code 0x20 */
  24. const replaceWithFullWidthForm = s => String.fromCharCode(s.charCodeAt(0) - 0x20 + 0xFF00);
  25. const PORT_ERROR_RE = /(Receiving end does not exist)|The message port closed before|moved into back\/forward cache|$/;
  26. export function initHooks() {
  27. const hooks = new Set();
  28. return {
  29. hook(cb) {
  30. hooks.add(cb);
  31. return () => hooks.delete(cb);
  32. },
  33. fire(...data) {
  34. // Set#forEach correctly iterates the remainder even if current callback unhooks itself
  35. hooks.forEach(cb => cb(...data));
  36. },
  37. };
  38. }
  39. /**
  40. * @param {string} cmd
  41. * @param data
  42. * @param {{retry?: boolean}} [options]
  43. * @return {Promise}
  44. */
  45. export function sendCmd(cmd, data, options) {
  46. // Firefox+Vue3 bug workaround for "Proxy object could not be cloned"
  47. if (!process.env.IS_INJECTED && IS_FIREFOX && isObject(data)) {
  48. data = deepCopy(data);
  49. }
  50. return sendMessage({ cmd, data }, options);
  51. }
  52. // These need `src` parameter so we'll use sendCmd for them. We could have forged `src` via
  53. // browser.tabs.getCurrent but there's no need as they normally use only a tiny amount of data.
  54. const COMMANDS_WITH_SRC = [
  55. 'ConfirmInstall',
  56. 'Notification',
  57. 'TabClose',
  58. 'TabFocus',
  59. 'TabOpen',
  60. /*
  61. These are used only by content scripts where sendCmdDirectly can't be used anyway
  62. 'GetInjected',
  63. 'GetRequestId',
  64. 'HttpRequest',
  65. 'InjectionFeedback',
  66. 'SetPopup',
  67. */
  68. ];
  69. export const getBgPage = () => browser.extension.getBackgroundPage?.();
  70. /**
  71. * Sends the command+data directly so it's synchronous and faster than sendCmd thanks to deepCopy.
  72. * WARNING! Make sure `cmd` handler doesn't use `src` or `cmd` is listed in COMMANDS_WITH_SRC.
  73. */
  74. export function sendCmdDirectly(cmd, data, options, fakeSrc) {
  75. const bg = !COMMANDS_WITH_SRC.includes(cmd) && getBgPage();
  76. const bgCopy = bg && bg !== window && bg.deepCopy;
  77. if (!bgCopy) {
  78. return sendCmd(cmd, data, options);
  79. }
  80. if (fakeSrc) {
  81. fakeSrc = bgCopy(fakeSrc);
  82. fakeSrc.fake = true;
  83. }
  84. return bg.handleCommandMessage(bgCopy({ cmd, data }), fakeSrc).then(deepCopy);
  85. }
  86. /**
  87. * @param {number} tabId
  88. * @param {string} cmd
  89. * @param data
  90. * @param {VMMessageTargetFrame} [options]
  91. * @return {Promise}
  92. */
  93. export function sendTabCmd(tabId, cmd, data, options) {
  94. return browser.tabs.sendMessage(tabId, { cmd, data }, options).catch(ignoreNoReceiver);
  95. }
  96. // Used by `injected`
  97. export function sendMessage(payload, { retry } = {}) {
  98. if (retry) return sendMessageRetry(payload);
  99. let promise = browser.runtime.sendMessage(payload);
  100. // Ignoring errors when sending from the extension script because it's a broadcast
  101. if (!process.env.IS_INJECTED) {
  102. promise = promise.catch(ignoreNoReceiver);
  103. }
  104. return promise;
  105. }
  106. /**
  107. * Used by `injected`
  108. * The active tab page and its [content] scripts load before the extension's
  109. * persistent background script when Chrome starts with a URL via command line
  110. * or when configured to restore the session, https://crbug.com/314686
  111. */
  112. export async function sendMessageRetry(payload, maxDuration = 10e3) {
  113. for (let start = performance.now(); performance.now() - start < maxDuration;) {
  114. try {
  115. const data = await sendMessage(payload);
  116. if (data !== undefined) {
  117. return data;
  118. }
  119. } catch (e) {
  120. if (!PORT_ERROR_RE.exec(e)[1]) {
  121. throw e;
  122. }
  123. }
  124. // Not using setTimeout which may be cleared by the web page
  125. await browser.storage.local.get(VIOLENTMONKEY);
  126. }
  127. throw new Error(VIOLENTMONKEY + ' cannot connect to the background page.');
  128. }
  129. export function ignoreNoReceiver(err) {
  130. if (!PORT_ERROR_RE.exec(err)[0]) {
  131. return Promise.reject(err);
  132. }
  133. }
  134. export function leftpad(input, length, pad = '0') {
  135. let num = input.toString();
  136. while (num.length < length) num = `${pad}${num}`;
  137. return num;
  138. }
  139. /**
  140. * @param {string} browserLang Language tags from RFC5646 (`[lang]-[script]-[region]-[variant]`, all parts are optional)
  141. * @param {string} locale `<lang>`, `<lang>-<region>`
  142. */
  143. function localeMatch(browserLang, metaLocale) {
  144. const bParts = browserLang.toLowerCase().split('-');
  145. const mParts = metaLocale.toLowerCase().split('-');
  146. let bi = 0;
  147. let mi = 0;
  148. while (bi < bParts.length && mi < mParts.length) {
  149. if (bParts[bi] === mParts[mi]) mi += 1;
  150. bi += 1;
  151. }
  152. return mi === mParts.length;
  153. }
  154. /**
  155. * Get locale attributes such as `@name:zh-CN`
  156. */
  157. export function getLocaleString(meta, key, languages = navigator.languages) {
  158. // zh, zh-cn, zh-tw
  159. const mls = Object.keys(meta)
  160. .filter(metaKey => metaKey.startsWith(key + ':'))
  161. .map(metaKey => metaKey.slice(key.length + 1))
  162. .sort((a, b) => b.length - a.length);
  163. let bestLocale;
  164. for (const lang of languages) {
  165. bestLocale = mls.find(ml => localeMatch(lang, ml));
  166. if (bestLocale) break;
  167. }
  168. return meta[bestLocale ? `${key}:${bestLocale}` : key] || '';
  169. }
  170. /**
  171. * @param {VMScript} script
  172. * @returns {string | undefined}
  173. */
  174. export function getScriptHome(script) {
  175. let meta;
  176. return script.custom[HOMEPAGE_URL]
  177. || (meta = script.meta)[HOMEPAGE_URL]
  178. || script[INFERRED]?.[HOMEPAGE_URL]
  179. || meta.homepage
  180. || meta.website
  181. || meta.source;
  182. }
  183. /**
  184. * @param {VMScript} script
  185. * @returns {string | undefined}
  186. */
  187. export function getScriptSupportUrl(script) {
  188. return script.meta[SUPPORT_URL] || script[INFERRED]?.[SUPPORT_URL];
  189. }
  190. /**
  191. * @param {VMScript} script
  192. * @returns {string}
  193. */
  194. export function getScriptIcon(script) {
  195. return script.custom.icon || script.meta.icon;
  196. }
  197. /**
  198. * @param {VMScript} script
  199. * @returns {string}
  200. */
  201. export function getScriptName(script) {
  202. return script.custom.name || getLocaleString(script.meta, 'name')
  203. || `#${script.props.id ?? i18n('labelNoName')}`;
  204. }
  205. /** @returns {VMInjection.RunAt} without "document-" */
  206. export function getScriptRunAt(script) {
  207. return `${script.custom[RUN_AT] || script.meta[RUN_AT] || ''}`.match(RUN_AT_RE)?.[1] || 'end';
  208. }
  209. /** URL that shows the name of the script and opens in devtools sources or in our editor */
  210. export function getScriptPrettyUrl(script, displayName) {
  211. return `${
  212. extensionRoot
  213. }${
  214. // When called from prepareScript, adding a space to group scripts in one block visually
  215. displayName && IS_FIREFOX ? '%20' : ''
  216. }${
  217. encodeURIComponent((displayName || getScriptName(script))
  218. .replace(BAD_URL_CHAR, replaceWithFullWidthForm))
  219. }.user.js#${
  220. script.props.id
  221. }`;
  222. }
  223. /**
  224. * @param {VMScript} script
  225. * @param {Object} [opts]
  226. * @param {boolean} [opts.all] - to return all two urls [checkUrl, downloadUrl]
  227. * @param {boolean} [opts.allowedOnly] - check shouldUpdate
  228. * @param {boolean} [opts.enabledOnly]
  229. * @return {string[] | string}
  230. */
  231. export function getScriptUpdateUrl(script, { all, allowedOnly, enabledOnly } = {}) {
  232. if ((!allowedOnly || script.config.shouldUpdate)
  233. && (!enabledOnly || script.config.enabled)) {
  234. const { custom, meta } = script;
  235. /* URL in meta may be set to an invalid value to enforce disabling of the automatic updates
  236. * e.g. GreasyFork sets it to `none` when the user installs an old version.
  237. * We'll show such script as non-updatable. */
  238. const downloadURL = tryUrl(custom.downloadURL || meta.downloadURL || custom.lastInstallURL);
  239. const updateURL = tryUrl(custom.updateURL || meta.updateURL || downloadURL);
  240. const url = downloadURL || updateURL;
  241. if (url) return all ? [downloadURL, updateURL] : url;
  242. }
  243. }
  244. export function getFullUrl(url, base) {
  245. let obj;
  246. try {
  247. obj = new URL(url, base);
  248. } catch (e) {
  249. return `data:,${e.message} ${url}`;
  250. }
  251. return obj.href;
  252. }
  253. export function encodeFilename(name) {
  254. // `escape` generated URI has % in it
  255. return name.replace(/[-\\/:*?"<>|%\s]/g, (m) => {
  256. let code = m.charCodeAt(0).toString(16);
  257. if (code.length < 2) code = `0${code}`;
  258. return `-${code}`;
  259. });
  260. }
  261. export function decodeFilename(filename) {
  262. return filename.replace(/-([0-9a-f]{2})/g, (_m, g) => String.fromCharCode(parseInt(g, 16)));
  263. }
  264. export async function getActiveTab(windowId = -2 /*chrome.windows.WINDOW_ID_CURRENT*/) {
  265. return (
  266. await browser.tabs.query({
  267. active: true,
  268. [kWindowId]: windowId,
  269. })
  270. )[0] || browserWindows && (
  271. // Chrome bug workaround when an undocked devtools window is focused
  272. await browser.tabs.query({
  273. active: true,
  274. [kWindowId]: (await browserWindows.getCurrent()).id,
  275. })
  276. )[0];
  277. }
  278. export function makePause(ms) {
  279. return ms < 0
  280. ? Promise.resolve()
  281. : new Promise(resolve => setTimeout(resolve, ms));
  282. }
  283. export function trueJoin(separator) {
  284. return this.filter(Boolean).join(separator);
  285. }
  286. /**
  287. * @param {string} raw - raw value in storage.cache
  288. * @param {string} [url]
  289. * @returns {?string}
  290. */
  291. export function makeDataUri(raw, url) {
  292. if (isDataUri(url)) return url;
  293. if (/^(i,|image\/)/.test(raw)) { // workaround for bugs in old VM, see 2e135cf7
  294. const i = raw.lastIndexOf(',');
  295. const type = raw.startsWith('image/') ? raw.slice(0, i) : 'image/png';
  296. return `data:${type};base64,${raw.slice(i + 1)}`;
  297. }
  298. return raw;
  299. }
  300. /**
  301. * @param {VMReq.Response} response
  302. * @returns {Promise<string>}
  303. */
  304. export async function makeRaw(response) {
  305. const type = (response.headers.get('content-type') || '').split(';')[0] || '';
  306. const body = await blob2base64(response.data);
  307. return `${type},${body}`;
  308. }
  309. export function loadQuery(string) {
  310. const res = {};
  311. if (string) {
  312. new URLSearchParams(string).forEach((val, key) => {
  313. res[key] = val;
  314. });
  315. }
  316. return res;
  317. }
  318. export function dumpQuery(dict) {
  319. return `${new URLSearchParams(dict)}`;
  320. }