gm-api.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import { dumpScriptValue, isEmpty } from '#/common/util';
  2. import bridge from './bridge';
  3. import store from './store';
  4. import { onTabCreate } from './tabs';
  5. import { onRequestCreate } from './requests';
  6. import { onNotificationCreate } from './notifications';
  7. import { decodeValue, dumpValue, loadValues, changeHooks } from './gm-values';
  8. import { jsonDump } from './util-web';
  9. import {
  10. NS_HTML, createNullObj, getUniqIdSafe, log,
  11. pickIntoThis, promiseResolve, vmOwnFunc,
  12. } from '../util';
  13. const {
  14. TextDecoder,
  15. URL: { createObjectURL, revokeObjectURL },
  16. } = global;
  17. const { decode: tdDecode } = TextDecoder[PROTO];
  18. const { indexOf: stringIndexOf } = '';
  19. let downloadChain = promiseResolve();
  20. export function makeGmApi() {
  21. return {
  22. __proto__: null,
  23. GM_deleteValue(key) {
  24. const { id } = this;
  25. const values = loadValues(id);
  26. const oldRaw = values[key];
  27. delete values[key];
  28. // using `undefined` to match the documentation and TM for GM_addValueChangeListener
  29. dumpValue(id, key, undefined, null, oldRaw, this);
  30. },
  31. GM_getValue(key, def) {
  32. const raw = loadValues(this.id)[key];
  33. return raw ? decodeValue(raw) : def;
  34. },
  35. GM_listValues() {
  36. return objectKeys(loadValues(this.id));
  37. },
  38. GM_setValue(key, val) {
  39. const { id } = this;
  40. const raw = dumpScriptValue(val, jsonDump) || null;
  41. const values = loadValues(id);
  42. const oldRaw = values[key];
  43. values[key] = raw;
  44. dumpValue(id, key, val, raw, oldRaw, this);
  45. },
  46. /**
  47. * @callback GMValueChangeListener
  48. * @param {String} key
  49. * @param {?} oldValue - `undefined` means value was created
  50. * @param {?} newValue - `undefined` means value was removed
  51. * @param {boolean} remote - `true` means value was modified in another tab
  52. */
  53. /**
  54. * @param {String} key - name of the value to monitor
  55. * @param {GMValueChangeListener} fn - callback
  56. * @returns {String} listenerId
  57. */
  58. GM_addValueChangeListener(key, fn) {
  59. if (typeof key !== 'string') key = `${key}`;
  60. if (typeof fn !== 'function') return;
  61. const keyHooks = changeHooks[this.id] || (changeHooks[this.id] = createNullObj());
  62. const hooks = keyHooks[key] || (keyHooks[key] = createNullObj());
  63. const i = objectValues(hooks)::indexOf(fn);
  64. let listenerId = i >= 0 && objectKeys(hooks)[i];
  65. if (!listenerId) {
  66. listenerId = getUniqIdSafe('VMvc');
  67. hooks[listenerId] = fn;
  68. }
  69. return listenerId;
  70. },
  71. /**
  72. * @param {String} listenerId
  73. */
  74. GM_removeValueChangeListener(listenerId) {
  75. const keyHooks = changeHooks[this.id];
  76. if (!keyHooks) return;
  77. for (const key in keyHooks) { /* proto is null */// eslint-disable-line guard-for-in
  78. const hooks = keyHooks[key];
  79. if (listenerId in hooks) {
  80. delete hooks[listenerId];
  81. if (isEmpty(hooks)) delete keyHooks[key];
  82. break;
  83. }
  84. }
  85. if (isEmpty(keyHooks)) delete changeHooks[this.id];
  86. },
  87. GM_getResourceText(name) {
  88. return getResource(this, name);
  89. },
  90. GM_getResourceURL(name) {
  91. return getResource(this, name, true);
  92. },
  93. GM_registerMenuCommand(cap, func) {
  94. const { id } = this;
  95. const key = `${id}:${cap}`;
  96. store.commands[key] = func;
  97. bridge.post('RegisterMenu', { id, cap }, this);
  98. return cap;
  99. },
  100. GM_unregisterMenuCommand(cap) {
  101. const { id } = this;
  102. const key = `${id}:${cap}`;
  103. delete store.commands[key];
  104. bridge.post('UnregisterMenu', { id, cap }, this);
  105. },
  106. GM_download(arg1, name) {
  107. // not using ... as it calls Babel's polyfill that calls unsafe Object.xxx
  108. const opts = createNullObj();
  109. let onload;
  110. if (typeof arg1 === 'string') {
  111. opts.url = arg1;
  112. opts.name = name;
  113. } else if (arg1) {
  114. name = arg1.name;
  115. onload = arg1.onload;
  116. opts::pickIntoThis(arg1, [
  117. 'url',
  118. 'headers',
  119. 'timeout',
  120. 'onerror',
  121. 'onprogress',
  122. 'ontimeout',
  123. ]);
  124. }
  125. if (!name || typeof name !== 'string') {
  126. throw new ErrorSafe('Required parameter "name" is missing or not a string.');
  127. }
  128. assign(opts, {
  129. context: { name, onload },
  130. method: 'GET',
  131. responseType: 'blob',
  132. overrideMimeType: 'application/octet-stream',
  133. onload: downloadBlob,
  134. });
  135. return onRequestCreate(opts, this);
  136. },
  137. GM_xmlhttpRequest(opts) {
  138. return onRequestCreate(opts, this);
  139. },
  140. /**
  141. * Bypasses site's CSP for inline `style`, `link`, and `script`.
  142. * @param {Node} [parent]
  143. * @param {string} tag
  144. * @param {Object} [attributes]
  145. * @returns {HTMLElement} it also has .then() so it should be compatible with TM
  146. */
  147. GM_addElement(parent, tag, attributes) {
  148. return typeof parent === 'string'
  149. ? webAddElement(null, parent, tag, this)
  150. : webAddElement(parent, tag, attributes, this);
  151. },
  152. /**
  153. * Bypasses site's CSP for inline `style`.
  154. * @param {string} css
  155. * @returns {HTMLElement} it also has .then() so it should be compatible with TM and old VM
  156. */
  157. GM_addStyle(css) {
  158. return webAddElement(null, 'style', { textContent: css, id: getUniqIdSafe('VMst') }, this);
  159. },
  160. GM_openInTab(url, options) {
  161. return onTabCreate(
  162. options && typeof options === 'object'
  163. ? assign(createNullObj(), options, { url })
  164. : { active: !options, url },
  165. this,
  166. );
  167. },
  168. GM_notification(text, title, image, onclick) {
  169. const options = typeof text === 'object' ? text : {
  170. __proto__: null,
  171. text,
  172. title,
  173. image,
  174. onclick,
  175. };
  176. if (!options.text) {
  177. throw new ErrorSafe('GM_notification: `text` is required!');
  178. }
  179. const id = onNotificationCreate(options, this);
  180. return {
  181. remove: vmOwnFunc(() => bridge.send('RemoveNotification', id, this)),
  182. };
  183. },
  184. GM_setClipboard(data, type) {
  185. bridge.post('SetClipboard', { data, type }, this);
  186. },
  187. // using the native console.log so the output has a clickable link to the caller's source
  188. GM_log: logging.log,
  189. };
  190. }
  191. function webAddElement(parent, tag, attrs, context) {
  192. let el;
  193. let errorInfo;
  194. const cbId = getUniqIdSafe();
  195. bridge.callbacks[cbId] = function _(res) {
  196. el = this;
  197. errorInfo = res;
  198. };
  199. bridge.post('AddElement', { tag, attrs, cbId }, context, parent);
  200. // DOM error in content script can't be caught by a page-mode userscript so we rethrow it here
  201. if (errorInfo) {
  202. const err = new ErrorSafe(errorInfo[0]);
  203. err.stack += `\n${errorInfo[1]}`;
  204. throw err;
  205. }
  206. /* A Promise polyfill is not actually necessary because DOM messaging is synchronous,
  207. but we keep it for compatibility with GM_addStyle in VM of 2017-2019
  208. https://github.com/violentmonkey/violentmonkey/issues/217
  209. as well as for GM_addElement in Tampermonkey. */
  210. defineProperty(el, 'then', {
  211. configurable: true,
  212. value(callback) {
  213. // prevent infinite resolve loop
  214. delete el.then;
  215. callback(el);
  216. },
  217. });
  218. return el;
  219. }
  220. function getResource(context, name, isBlob) {
  221. const key = context.resources[name];
  222. if (key) {
  223. let res = isBlob && context.urls[key];
  224. if (!res) {
  225. const raw = bridge.cache[context.pathMap[key] || key];
  226. if (raw) {
  227. // TODO: move into `content`
  228. const dataPos = raw::stringIndexOf(',');
  229. const bin = window::atobSafe(dataPos < 0 ? raw : raw::slice(dataPos + 1));
  230. if (isBlob || /[\x80-\xFF]/::regexpTest(bin)) {
  231. const len = bin.length;
  232. const bytes = new Uint8ArraySafe(len);
  233. for (let i = 0; i < len; i += 1) {
  234. bytes[i] = bin::charCodeAt(i);
  235. }
  236. if (isBlob) {
  237. const type = dataPos < 0 ? '' : raw::slice(0, dataPos);
  238. res = createObjectURL(new BlobSafe([bytes], { type }));
  239. context.urls[key] = res;
  240. } else {
  241. res = new TextDecoder()::tdDecode(bytes);
  242. }
  243. } else { // pure ASCII
  244. res = bin;
  245. }
  246. } else if (isBlob) {
  247. res = key;
  248. }
  249. }
  250. return res;
  251. }
  252. }
  253. function downloadBlob(res) {
  254. // TODO: move into `content`
  255. const { context: { name, onload }, response } = res;
  256. const url = createObjectURL(response);
  257. const a = document::createElementNS(NS_HTML, 'a');
  258. a::setAttribute('href', url);
  259. if (name) a::setAttribute('download', name);
  260. downloadChain = downloadChain::then(async () => {
  261. a::fire(new MouseEventSafe('click'));
  262. revokeBlobAfterTimeout(url);
  263. try { if (onload) onload(res); } catch (e) { log('error', ['GM_download', 'callback'], e); }
  264. await bridge.send('SetTimeout', 100);
  265. });
  266. }
  267. async function revokeBlobAfterTimeout(url) {
  268. await bridge.send('SetTimeout', 3000);
  269. revokeObjectURL(url);
  270. }