Browse Source

feat: add 'BrowserAPIService'

Ahmad Kholid 1 year ago
parent
commit
04e2d3c4f0

+ 1 - 1
jsconfig.json

@@ -6,7 +6,7 @@
       "@business": ["business/dev/*"]
     },
     "module": "ESNext",
-    "target": "ES6"
+    "target": "ES2020"
   },
   "include": ["src/**/*", "utils/**/*"]
 }

+ 3 - 0
src/background/BackgroundOffscreen.js

@@ -1,5 +1,6 @@
 /* eslint-disable class-methods-use-this */
 import { IS_FIREFOX } from '@/common/utils/constant';
+import { sleep } from '@/utils/helper';
 import { MessageListener } from '@/utils/message';
 import Browser from 'webextension-polyfill';
 
@@ -49,6 +50,8 @@ class BackgroundOffscreen {
       ],
       justification: 'For running the workflow',
     });
+
+    await sleep(500);
   }
 
   /**

+ 9 - 0
src/background/index.js

@@ -3,6 +3,8 @@ import { MessageListener } from '@/utils/message';
 import { sleep } from '@/utils/helper';
 import getFile, { readFileAsBase64 } from '@/utils/getFile';
 import automa from '@business';
+import BrowserAPIService from '@/service/browser-api/BrowserAPIService';
+import BrowserAPIEventHandler from '@/service/browser-api/BrowserAPIEventHandler';
 import { registerWorkflowTrigger } from '../utils/workflowTrigger';
 import BackgroundUtils from './BackgroundUtils';
 import BackgroundWorkflowUtils from './BackgroundWorkflowUtils';
@@ -47,6 +49,13 @@ if (browser.notifications && browser.notifications.onClicked) {
 
 const message = new MessageListener('background');
 
+message.on('browser-api', (payload) => {
+  return BrowserAPIService.runtimeMessageHandler(payload);
+});
+message.on(BrowserAPIEventHandler.RuntimeEvents.TOGGLE, (data) =>
+  BrowserAPIEventHandler.instance.onToggleBrowserEventListener(data)
+);
+
 message.on('fetch', async ({ type, resource }) => {
   const response = await fetch(resource.url, resource);
   if (!response.ok) throw new Error(response.statusText);

+ 1 - 1
src/offscreen/index.html

@@ -6,6 +6,6 @@
   <title>Offscreen</title>
 </head>
 <body>
-
+  <iframe src="/sandbox.html" id="sandbox" style="display: none;"></iframe>
 </body>
 </html>

+ 7 - 2
src/offscreen/message-listener.js

@@ -1,9 +1,10 @@
+import BrowserAPIEventHandler from '@/service/browser-api/BrowserAPIEventHandler';
 import { MessageListener } from '@/utils/message';
 import WorkflowManager from '@/workflowEngine/WorkflowManager';
-import { runtime } from 'webextension-polyfill';
+import Browser from 'webextension-polyfill';
 
 const messageListener = new MessageListener('offscreen');
-runtime.onMessage.addListener(messageListener.listener);
+Browser.runtime.onMessage.addListener(messageListener.listener);
 
 messageListener.on('workflow:execute', (data) => {
   WorkflowManager.instance.execute(data);
@@ -20,3 +21,7 @@ messageListener.on('workflow:resume', ({ id, nextBlock }) => {
 messageListener.on('workflow:update', ({ id, data }) => {
   WorkflowManager.instance.updateExecution(id, data);
 });
+
+messageListener.on(BrowserAPIEventHandler.RuntimeEvents.ON_EVENT, (event) =>
+  BrowserAPIEventHandler.instance.onBrowserEventListener(event)
+);

+ 156 - 0
src/service/browser-api/BrowserAPIEventHandler.js

@@ -0,0 +1,156 @@
+/* eslint-disable class-methods-use-this */
+
+import { MessageListener } from '@/utils/message';
+import { browserAPIMap } from './browser-api-map';
+
+const BROWSER_API_EVENTS = {
+  ON_EVENT: 'browser-api:on-browser-event',
+  TOGGLE: 'browser-api:toggle-browser-event-listener',
+};
+
+function onBrowserAPIEvent(name, ...args) {
+  MessageListener.sendMessage(
+    BROWSER_API_EVENTS.ON_EVENT,
+    { name, args },
+    'offscreen'
+  );
+}
+
+/**
+ * @typedef { 'chrome.debugger.onEvent' | 'browser.tabs.onRemoved' | 'browser.webNavigation.onCreatedNavigationTarget' | 'browser.windows.onRemoved' } BrowserAPIEventsName
+ */
+
+class BrowserAPIEventHandler {
+  /** @type {BrowserAPIEventHandler} */
+  static #_instance;
+
+  /**
+   * BrowserAPIEventHandler singleton
+   * @type {BrowserAPIEventHandler}
+   */
+  static get instance() {
+    if (!this.#_instance) this.#_instance = new BrowserAPIEventHandler();
+
+    return this.#_instance;
+  }
+
+  static RuntimeEvents = BROWSER_API_EVENTS;
+
+  /** @type {Record<string, ((...args: unknown[]) => void)[]>} */
+  #events;
+
+  /** @type {Record<string, { addListener: (...args: unknown[]) => void, removeListener: (...args: unknown[]) => void }>} */
+  #eventsHandler;
+
+  /** @type {Set<string>} */
+  #isEventAdded;
+
+  /** @type {Record<string, ((...args: unknown[]) => void)>} */
+  #browserEvents;
+
+  constructor() {
+    this.#events = {};
+    this.#browserEvents = {};
+    this.#eventsHandler = {};
+
+    this.#isEventAdded = new Set();
+  }
+
+  /**
+   * @param {BrowserAPIEventsName} name
+   * @param {*} browserAPI
+   */
+  createEventListener(name) {
+    if (this.#eventsHandler[name]) return this.#eventsHandler[name];
+
+    if (!this.#events[name]) this.#events[name] = [];
+
+    /**
+     * This callback is displayed as a global member.
+     * @callback eventListenerCallback
+     * @param {...*} args
+     */
+
+    /**
+     * @param {eventListenerCallback} callback
+     */
+    const addListener = (callback) => {
+      this.#events[name].push(callback);
+
+      if (this.#isEventAdded.has(name)) return;
+
+      MessageListener.sendMessage(
+        BROWSER_API_EVENTS.TOGGLE,
+        {
+          name,
+          type: 'add',
+        },
+        'background'
+      );
+    };
+
+    /**
+     * @param {eventListenerCallback} callback
+     */
+    const removeListener = (callback) => {
+      const index = this.#events[name].indexOf(callback);
+      if (index === -1) return;
+
+      this.#events[name].splice(index, 1);
+
+      if (this.#events[name].length > 0) return;
+
+      MessageListener.sendMessage(
+        BROWSER_API_EVENTS.TOGGLE,
+        {
+          name,
+          type: 'remove',
+        },
+        'background'
+      );
+      delete this.#eventsHandler[name];
+    };
+
+    this.#eventsHandler[name] = {
+      addListener,
+      removeListener,
+    };
+
+    return this.#eventsHandler[name];
+  }
+
+  /**
+   * @param {{ name: BrowserAPIEventsName, args: unknown[] }} event
+   */
+  onBrowserEventListener(event) {
+    if (!event.name || !this.#events[event.name]) return;
+
+    this.#events[event.name].forEach((listener) => listener(...event.args));
+  }
+
+  /**
+   * @param {{ name: BrowserAPIEventsName, type: 'add' | 'remove' }} data
+   */
+  onToggleBrowserEventListener({ name, type }) {
+    const isAddListener = type === 'add';
+
+    if (isAddListener && this.#browserEvents[name]) return;
+    if (!isAddListener && !this.#browserEvents[name]) return;
+
+    const browserEventAPI = browserAPIMap
+      .find((item) => item.path === name && item.isEvent)
+      ?.api();
+    if (!browserAPIMap) return;
+
+    const eventType = isAddListener ? 'addListener' : 'removeListener';
+    const listener = isAddListener
+      ? onBrowserAPIEvent.bind(null, name)
+      : this.#browserEvents[name];
+
+    if (isAddListener) this.#browserEvents[name] = listener;
+
+    browserEventAPI[eventType](listener);
+  }
+}
+
+export default BrowserAPIEventHandler;

+ 178 - 0
src/service/browser-api/BrowserAPIService.js

@@ -0,0 +1,178 @@
+/* eslint-disable max-classes-per-file */
+/* eslint-disable prefer-rest-params */
+import objectPath from 'object-path';
+import Browser from 'webextension-polyfill';
+import { MessageListener } from '@/utils/message';
+import BrowserAPIEventHandler from './BrowserAPIEventHandler';
+import { browserAPIMap } from './browser-api-map';
+
+/**
+ * @typedef {Object} ScriptInjectTarget
+ * @property {number} tabId
+ * @property {number=} frameId
+ * @property {boolean=} allFrames
+ */
+
+// Maybe there's a better way?
+export const IS_BROWSER_API_AVAILABLE = 'tabs' in Browser;
+
+function sendBrowserApiMessage(name, ...args) {
+  return MessageListener.sendMessage(
+    'browser-api',
+    {
+      name,
+      args,
+    },
+    'background'
+  );
+}
+
+class BrowserConentScript {
+  /**
+   * Inject content script into targeted tab
+   * @param {Object} script
+   * @param {ScriptInjectTarget} script.target
+   * @param {string} script.file
+   * @param {boolean=} script.injectImmediately
+   * @param {(boolean|{timeoutMs?: number, maxTry?: number, messageId?: string})=} script.waitUntilInjected
+   * @returns {Promise<boolean>}
+   */
+  static async inject({ file, target, injectImmediately, waitUntilInjected }) {
+    if (!IS_BROWSER_API_AVAILABLE) {
+      return sendBrowserApiMessage('contentScript.inject', ...arguments);
+    }
+
+    const frameId =
+      Object.hasOwn(target, 'frameId') && !target.allFrames
+        ? target.frameId
+        : undefined;
+
+    if (Browser.tabs.injectContentScript) {
+      await Browser.tabs.executeScript(target.tabId, {
+        file,
+        frameId,
+        allFrames: target.allFrames,
+      });
+    } else {
+      await Browser.scripting.executeScript({
+        target: {
+          tabId: target.tabId,
+          allFrames: target.allFrames,
+          frameIds: typeof frameId === 'number' ? [frameId] : undefined,
+        },
+        files: [file],
+        injectImmediately,
+      });
+    }
+
+    if (!waitUntilInjected) return true;
+
+    const maxTryCount = waitUntilInjected.maxTry ?? 3;
+    const timeoutMs = waitUntilInjected.timeoutMs ?? 1000;
+
+    let tryCount = 0;
+
+    return new Promise((resolve) => {
+      const checkIfInjected = async () => {
+        try {
+          if (tryCount > maxTryCount) {
+            resolve(false);
+            return;
+          }
+
+          tryCount += 1;
+
+          const isInjected = await this.isContentScriptInjected(
+            target,
+            waitUntilInjected.messageId
+          );
+          if (isInjected) {
+            resolve(true);
+            return;
+          }
+
+          setTimeout(() => checkIfInjected(), timeoutMs);
+        } catch (error) {
+          console.error(error);
+          setTimeout(() => checkIfInjected(), timeoutMs);
+        }
+      };
+      checkIfInjected();
+    });
+  }
+
+  /**
+   * Check if content script injected
+   * @param {ScriptInjectTarget} target
+   * @param {string=} messageId
+   */
+  static async isInjected({ tabId, allFrames, frameId }, messageId) {
+    if (!IS_BROWSER_API_AVAILABLE) {
+      return sendBrowserApiMessage('contentScript.isInjected', ...arguments);
+    }
+
+    try {
+      await Browser.tabs.sendMessage(
+        tabId,
+        { type: messageId || 'content-script-exists' },
+        { frameId: allFrames ? undefined : frameId }
+      );
+
+      return true;
+    } catch (error) {
+      return false;
+    }
+  }
+}
+
+class BrowserAPIService {
+  /**
+   * Handle runtime message that send by BrowserAPIService when API is not available
+   * @param {{ name: string; args: any[] }} payload;
+   */
+  static runtimeMessageHandler({ args, name }) {
+    const apiHandler = objectPath.get(this, name);
+    if (!apiHandler) throw new Error(`"${name}" is invalid method`);
+
+    return apiHandler(...args);
+  }
+
+  static runtime = Browser.runtime;
+
+  /** @type {typeof Browser.tabs} */
+  static tabs;
+
+  /** @type {typeof Browser.proxy} */
+  static proxy;
+
+  /** @type {typeof Browser.storage} */
+  static storage;
+
+  /** @type {typeof Browser.windows} */
+  static windows;
+
+  /** @type {typeof chrome.debugger} */
+  static debugger;
+
+  /** @type {typeof Browser.webNavigation} */
+  static webNavigation;
+
+  static contentScript = BrowserConentScript;
+}
+
+(() => {
+  browserAPIMap.forEach((item) => {
+    let value;
+    if (IS_BROWSER_API_AVAILABLE) {
+      value = item.api();
+    } else {
+      value = item.isEvent
+        ? BrowserAPIEventHandler.instance.createEventListener(item.path)
+        : (...args) => sendBrowserApiMessage(item.path, ...args);
+    }
+
+    objectPath.set(BrowserAPIService, item.path, value);
+  });
+})();
+
+export default BrowserAPIService;

+ 28 - 0
src/service/browser-api/browser-api-map.js

@@ -0,0 +1,28 @@
+import Browser from 'webextension-polyfill';
+
+/** @type {{api: unknown, path: string; isEvent?: true}[]} */
+export const browserAPIMap = [
+  { api: () => Browser.tabs.get, path: 'tabs.get' },
+  { api: () => Browser.tabs.query, path: 'tabs.query' },
+  { isEvent: true, api: () => Browser.tabs.onRemoved, path: 'tabs.onRemoved' },
+  {
+    isEvent: true,
+    path: 'webNavigation.onCreatedNavigationTarget',
+    api: () => Browser.webNavigation.onCreatedNavigationTarget,
+  },
+  { api: () => Browser.windows.update, path: 'windows.update' },
+  { api: () => Browser.windows.create, path: 'windows.create' },
+  {
+    isEvent: true,
+    path: 'windows.onRemoved',
+    api: () => Browser.windows.onRemoved,
+  },
+  { api: () => Browser.storage.local.get, path: 'storage.local.get' },
+  { api: () => Browser.storage.local.set, path: 'storage.local.set' },
+  { api: () => Browser.storage.local.remove, path: 'storage.local.remove' },
+  { api: () => Browser.proxy.settings.clear, path: 'proxy.settings.clear' },
+  {
+    api: () => chrome.debugger.onEvent.addListener,
+    path: 'debugger.onEvent.addListener',
+  },
+];

+ 31 - 27
src/workflowEngine/WorkflowEngine.js

@@ -1,9 +1,9 @@
 import { nanoid } from 'nanoid';
-import browser from 'webextension-polyfill';
 import cloneDeep from 'lodash.clonedeep';
 import { getBlocks } from '@/utils/getSharedData';
 import { clearCache, sleep, parseJSON, isObject } from '@/utils/helper';
 import dbStorage from '@/db/storage';
+import BrowserAPIService from '@/service/browser-api/BrowserAPIService';
 import WorkflowWorker from './WorkflowWorker';
 
 let blocks = getBlocks();
@@ -19,7 +19,6 @@ class WorkflowEngine {
     this.isTestingMode = workflow.testingMode;
     this.parentWorkflow = options?.parentWorkflow;
     this.saveLog = workflow.settings?.saveLog ?? true;
-    this.isMV2 = browser.runtime.getManifest().manifest_version === 2;
 
     this.workerId = 0;
     this.workers = new Map();
@@ -153,41 +152,45 @@ class WorkflowEngine {
         this.eventListeners = {};
 
         if (triggerBlock.data.preferParamsInTab) {
-          const [activeTab] = await browser.tabs.query({
+          const [activeTab] = await BrowserAPIService.tabs.query({
             active: true,
             url: '*://*/*',
             lastFocusedWindow: true,
           });
           if (activeTab) {
-            const result = await browser.tabs.sendMessage(activeTab.id, {
-              type: 'input-workflow-params',
-              data: {
-                workflow: this.workflow,
-                params: triggerBlock.data.parameters,
-              },
-            });
+            const result = await BrowserAPIService.tabs.sendMessage(
+              activeTab.id,
+              {
+                type: 'input-workflow-params',
+                data: {
+                  workflow: this.workflow,
+                  params: triggerBlock.data.parameters,
+                },
+              }
+            );
 
             if (result) return;
           }
         }
 
-        const paramUrl = browser.runtime.getURL('params.html');
-        const tabs = await browser.tabs.query({});
+        const paramUrl = BrowserAPIService.runtime.getURL('params.html');
+        const tabs = await BrowserAPIService.tabs.query({});
         const paramTab = tabs.find((tab) => tab.url?.includes(paramUrl));
 
         if (paramTab) {
-          browser.tabs.sendMessage(paramTab.id, {
+          await BrowserAPIService.tabs.sendMessage(paramTab.id, {
             name: 'workflow:params',
             data: this.workflow,
           });
-
-          browser.windows.update(paramTab.windowId, { focused: true });
+          await BrowserAPIService.windows.update(paramTab.windowId, {
+            focused: true,
+          });
         } else {
-          browser.windows.create({
+          BrowserAPIService.windows.create({
             type: 'popup',
             width: 480,
             height: 700,
-            url: browser.runtime.getURL(
+            url: BrowserAPIService.runtime.getURL(
               `/params.html?workflowId=${this.workflow.id}`
             ),
           });
@@ -253,14 +256,14 @@ class WorkflowEngine {
       if (BROWSER_TYPE !== 'chrome') {
         this.workflow.settings.debugMode = false;
       } else if (this.workflow.settings.debugMode) {
-        chrome.debugger.onEvent.addListener(this.onDebugEvent);
+        BrowserAPIService.debugger.onEvent.addListener(this.onDebugEvent);
       }
       if (
         this.workflow.settings.reuseLastState &&
         !this.workflow.connectedTable
       ) {
         const lastStateKey = `state:${this.workflow.id}`;
-        const value = await browser.storage.local.get(lastStateKey);
+        const value = await BrowserAPIService.storage.localGet(lastStateKey);
         const lastState = value[lastStateKey];
 
         if (lastState) {
@@ -269,9 +272,8 @@ class WorkflowEngine {
         }
       }
 
-      const { settings: userSettings } = await browser.storage.local.get(
-        'settings'
-      );
+      const { settings: userSettings } =
+        await BrowserAPIService.storage.localGet('settings');
       this.logsLimit = userSettings?.logsLimit || 1001;
 
       this.workflow.table = columns;
@@ -373,7 +375,9 @@ class WorkflowEngine {
   }
 
   async executeQueue() {
-    const { workflowQueue } = await browser.storage.local.get('workflowQueue');
+    const { workflowQueue } = await BrowserAPIService.storage.localGet(
+      'workflowQueue'
+    );
     const queueIndex = (workflowQueue || []).indexOf(this.workflow?.id);
 
     if (!workflowQueue || queueIndex === -1) return;
@@ -387,7 +391,7 @@ class WorkflowEngine {
 
     workflowQueue.splice(queueIndex, 1);
 
-    await browser.storage.local.set({ workflowQueue });
+    await BrowserAPIService.storage.localSet({ workflowQueue });
   }
 
   async destroyWorker(workerId) {
@@ -430,9 +434,9 @@ class WorkflowEngine {
 
     try {
       if (this.isDestroyed) return;
-      if (this.isUsingProxy) browser.proxy.settings.clear({});
+      if (this.isUsingProxy) BrowserAPIService.proxy.clearSettings({});
       if (this.workflow.settings.debugMode && BROWSER_TYPE === 'chrome') {
-        chrome.debugger.onEvent.removeListener(this.onDebugEvent);
+        BrowserAPIService.debugger.onEvent.removeListener(this.onDebugEvent);
 
         await sleep(1000);
 
@@ -471,7 +475,7 @@ class WorkflowEngine {
           },
         };
 
-        browser.storage.local.set(workflowState);
+        BrowserAPIService.storage.localSet(workflowState);
       } else if (status === 'success') {
         clearCache(this.workflow);
       }

+ 10 - 7
src/workflowEngine/WorkflowWorker.js

@@ -1,4 +1,3 @@
-import browser from 'webextension-polyfill';
 import cloneDeep from 'lodash.clonedeep';
 import {
   toCamelCase,
@@ -8,10 +7,10 @@ import {
   isObject,
 } from '@/utils/helper';
 import dbStorage from '@/db/storage';
+import BrowserAPIService from '@/service/browser-api/BrowserAPIService';
 import templating from './templating';
 import renderString from './templating/renderString';
 import { convertData, waitTabLoaded } from './helper';
-import injectContentScript from './injectContentScript';
 
 function blockExecutionWrapper(blockHandler, blockData) {
   return new Promise((resolve, reject) => {
@@ -518,7 +517,7 @@ class WorkflowWorker {
         frameSelector: this.frameSelector,
         ...payload,
       };
-      const data = await browser.tabs.sendMessage(
+      const data = await BrowserAPIService.tabs.sendMessage(
         this.activeTab.id,
         messagePayload,
         { frameId: this.activeTab.frameId, ...options }
@@ -533,10 +532,14 @@ class WorkflowWorker {
       const channelClosed = error.message?.includes('message channel closed');
 
       if (noConnection || channelClosed) {
-        const isScriptInjected = await injectContentScript(
-          this.activeTab.id,
-          this.activeTab.frameId
-        );
+        const isScriptInjected = await BrowserAPIService.contentScript.inject({
+          file: './contentScript.bundle.js',
+          target: {
+            tabId: this.activeTab.id,
+            frameId: this.activeTab.frameId,
+          },
+          waitUntilInjected: true,
+        });
 
         if (isScriptInjected) {
           const result = await this._sendMessageToTab(

+ 7 - 7
src/workflowEngine/blocksHandler/handlerBrowserEvent.js

@@ -1,5 +1,5 @@
-import browser from 'webextension-polyfill';
 import { isWhitespace } from '@/utils/helper';
+import BrowserAPIService from '@/service/browser-api/BrowserAPIService';
 
 function handleEventListener(target, validate) {
   return (data, activeTab) => {
@@ -35,7 +35,7 @@ function onTabLoaded({ tabLoadedUrl, activeTabLoaded, timeout }, { id }) {
         return;
       }
 
-      browser.tabs
+      BrowserAPIService.tabs
         .get(id)
         .then((tab) => {
           if (tab.status === 'complete') {
@@ -52,7 +52,7 @@ function onTabLoaded({ tabLoadedUrl, activeTabLoaded, timeout }, { id }) {
       ? '<all_urls>'
       : tabLoadedUrl.replace(/\s/g, '').split(',');
     const checkTabsStatus = () => {
-      browser.tabs
+      BrowserAPIService.tabs
         .query({
           url,
           status: 'loading',
@@ -90,16 +90,16 @@ const validateCreatedTab = ({ url }, { data }) => {
 };
 const events = {
   'tab:loaded': onTabLoaded,
-  'tab:close': handleEventListener(browser.tabs.onRemoved),
+  'tab:close': handleEventListener(BrowserAPIService.tabs.onRemoved),
   'tab:create': handleEventListener(
-    browser.webNavigation.onCreatedNavigationTarget,
+    BrowserAPIService.webNavigation.onCreatedNavigationTarget,
     validateCreatedTab
   ),
   'window:create': handleEventListener(
-    browser.webNavigation.onCreatedNavigationTarget,
+    BrowserAPIService.webNavigation.onCreatedNavigationTarget,
     validateCreatedTab
   ),
-  'window:close': handleEventListener(browser.windows.onRemoved),
+  'window:close': handleEventListener(BrowserAPIService.windows.onRemoved),
 };
 
 export default async function ({ data, id }) {

+ 1 - 2
src/workflowEngine/blocksHandler/handlerConditions.js

@@ -1,10 +1,9 @@
-import browser from 'webextension-polyfill';
 import compareBlockValue from '@/utils/compareBlockValue';
 import testConditions from '../utils/testConditions';
 import renderString from '../templating/renderString';
 import checkCodeCondition from '../utils/conditionCode';
 
-const isMV2 = browser.runtime.getManifest().manifest_version === 2;
+const isMV2 = false;
 
 function checkConditions(data, conditionOptions) {
   return new Promise((resolve, reject) => {

+ 1 - 1
src/workflowEngine/utils/conditionCode.js

@@ -3,7 +3,7 @@ import browser from 'webextension-polyfill';
 import { automaRefDataStr, messageSandbox, checkCSPAndInject } from '../helper';
 
 const nanoid = customAlphabet('1234567890abcdef', 5);
-const isMV2 = browser.runtime.getManifest().manifest_version === 2;
+const isMV2 = false;
 
 export default async function (activeTab, payload) {
   const variableId = `automa${nanoid()}`;