BrowserAPIService.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /* eslint-disable max-classes-per-file */
  2. /* eslint-disable prefer-rest-params */
  3. import objectPath from 'object-path';
  4. import Browser from 'webextension-polyfill';
  5. import { MessageListener } from '@/utils/message';
  6. import BrowserAPIEventHandler from './BrowserAPIEventHandler';
  7. import { browserAPIMap } from './browser-api-map';
  8. /**
  9. * @typedef {Object} ScriptInjectTarget
  10. * @property {number} tabId
  11. * @property {number=} frameId
  12. * @property {boolean=} allFrames
  13. */
  14. // Maybe there's a better way?
  15. export const IS_BROWSER_API_AVAILABLE = 'tabs' in Browser;
  16. function sendBrowserApiMessage(name, ...args) {
  17. return MessageListener.sendMessage(
  18. 'browser-api',
  19. {
  20. name,
  21. args,
  22. },
  23. 'background'
  24. );
  25. }
  26. class BrowserConentScript {
  27. /**
  28. * Inject content script into targeted tab
  29. * @param {Object} script
  30. * @param {ScriptInjectTarget} script.target
  31. * @param {string} script.file
  32. * @param {boolean=} script.injectImmediately
  33. * @param {(boolean|{timeoutMs?: number, maxTry?: number, messageId?: string})=} script.waitUntilInjected
  34. * @returns {Promise<boolean>}
  35. */
  36. static async inject({ file, target, injectImmediately, waitUntilInjected }) {
  37. if (!IS_BROWSER_API_AVAILABLE) {
  38. return sendBrowserApiMessage('contentScript.inject', ...arguments);
  39. }
  40. const frameId =
  41. Object.hasOwn(target, 'frameId') && !target.allFrames
  42. ? target.frameId
  43. : undefined;
  44. if (Browser.tabs.injectContentScript) {
  45. await Browser.tabs.executeScript(target.tabId, {
  46. file,
  47. frameId,
  48. allFrames: target.allFrames,
  49. });
  50. } else {
  51. await Browser.scripting.executeScript({
  52. target: {
  53. tabId: target.tabId,
  54. allFrames: target.allFrames,
  55. frameIds: typeof frameId === 'number' ? [frameId] : undefined,
  56. },
  57. files: [file],
  58. injectImmediately,
  59. });
  60. }
  61. if (!waitUntilInjected) return true;
  62. const maxTryCount = waitUntilInjected.maxTry ?? 3;
  63. const timeoutMs = waitUntilInjected.timeoutMs ?? 1000;
  64. let tryCount = 0;
  65. return new Promise((resolve) => {
  66. const checkIfInjected = async () => {
  67. try {
  68. if (tryCount > maxTryCount) {
  69. resolve(false);
  70. return;
  71. }
  72. tryCount += 1;
  73. const isInjected = await this.isContentScriptInjected(
  74. target,
  75. waitUntilInjected.messageId
  76. );
  77. if (isInjected) {
  78. resolve(true);
  79. return;
  80. }
  81. setTimeout(() => checkIfInjected(), timeoutMs);
  82. } catch (error) {
  83. console.error(error);
  84. setTimeout(() => checkIfInjected(), timeoutMs);
  85. }
  86. };
  87. checkIfInjected();
  88. });
  89. }
  90. /**
  91. * Check if content script injected
  92. * @param {ScriptInjectTarget} target
  93. * @param {string=} messageId
  94. */
  95. static async isInjected({ tabId, allFrames, frameId }, messageId) {
  96. if (!IS_BROWSER_API_AVAILABLE) {
  97. return sendBrowserApiMessage('contentScript.isInjected', ...arguments);
  98. }
  99. try {
  100. await Browser.tabs.sendMessage(
  101. tabId,
  102. { type: messageId || 'content-script-exists' },
  103. { frameId: allFrames ? undefined : frameId }
  104. );
  105. return true;
  106. } catch (error) {
  107. return false;
  108. }
  109. }
  110. }
  111. class BrowserAPIService {
  112. /**
  113. * Handle runtime message that send by BrowserAPIService when API is not available
  114. * @param {{ name: string; args: any[] }} payload;
  115. */
  116. static runtimeMessageHandler({ args, name }) {
  117. const apiHandler = objectPath.get(this, name);
  118. if (!apiHandler) throw new Error(`"${name}" is invalid method`);
  119. return apiHandler(...args);
  120. }
  121. static runtime = Browser.runtime;
  122. /** @type {typeof Browser.tabs} */
  123. static tabs;
  124. /** @type {typeof Browser.proxy} */
  125. static proxy;
  126. /** @type {typeof Browser.storage} */
  127. static storage;
  128. /** @type {typeof Browser.windows} */
  129. static windows;
  130. /** @type {typeof chrome.debugger} */
  131. static debugger;
  132. /** @type {typeof Browser.webNavigation} */
  133. static webNavigation;
  134. /** @type {typeof Browser.permissions} */
  135. static permissions;
  136. /** @type {typeof Browser.downloads} */
  137. static downloads;
  138. /** @type {typeof Browser.notifications} */
  139. static notifications;
  140. /** @type {typeof Browser.browserAction} */
  141. static browserAction;
  142. /** @type {typeof Browser.extension} */
  143. static extension;
  144. static contentScript = BrowserConentScript;
  145. }
  146. (() => {
  147. browserAPIMap.forEach((item) => {
  148. let value;
  149. if (IS_BROWSER_API_AVAILABLE) {
  150. value = item.api();
  151. } else {
  152. value = item.isEvent
  153. ? BrowserAPIEventHandler.instance.createEventListener(item.path)
  154. : (...args) => sendBrowserApiMessage(item.path, ...args);
  155. }
  156. objectPath.set(BrowserAPIService, item.path, value);
  157. });
  158. })();
  159. export default BrowserAPIService;