Browse Source

Merge pull request #93 from Kholid060/dev

v0.5.1
Ahmad Kholid 3 years ago
parent
commit
7125c53899

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.4.7",
+  "version": "0.5.1",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {

+ 2 - 17
src/assets/css/drawflow.css

@@ -12,7 +12,7 @@
   display: flex;
   overflow: hidden;
   touch-action: none;
-  outline: 0
+  outline: none;
 }
 
 .drawflow {
@@ -24,6 +24,7 @@
 
 .drawflow .drawflow-node {
   position: absolute;
+  align-items: center;
   background: white;
   min-width: 150px;
   min-height: 40px;
@@ -110,7 +111,6 @@
   stroke: #000;
   stroke-width: 2;
   fill: #fff;
-  transform: translate(-9999px, -9999px);
 }
 
 .drawflow .connection .point.selected,
@@ -124,21 +124,6 @@
   stroke: theme('colors.accent');
 }
 
-.drawflow .selectbox {
-  z-index: 3;
-  position: absolute;
-  transform: translate(9999px, 9999px)
-}
-
-.drawflow .selectbox rect {
-  fill: #00f;
-  opacity: .5;
-  stroke: #ff0;
-  stroke-width: 5;
-  stroke-opacity: .5;
-  transform: translate(-9999px, -9999px)
-}
-
 .drawflow-delete {
   position: absolute;
   display: block;

+ 22 - 7
src/background/collection-engine/index.js

@@ -3,6 +3,7 @@ import browser from 'webextension-polyfill';
 import { toCamelCase } from '@/utils/helper';
 import * as flowHandler from './flow-handler';
 import workflowState from '../workflow-state';
+import WorkflowEngine from '../workflow-engine';
 
 class CollectionEngine {
   constructor(collection) {
@@ -24,17 +25,31 @@ class CollectionEngine {
     try {
       if (this.collection.flow.length === 0) return;
 
-      await workflowState.add(this.id, {
-        state: this.state,
-        isCollection: true,
-        collectionId: this.collection.id,
-      });
-
       const { workflows } = await browser.storage.local.get('workflows');
 
       this.workflows = workflows;
       this.startedTimestamp = Date.now();
-      this._flowHandler(this.collection.flow[0]);
+
+      if (this.collection?.options.atOnce) {
+        this.collection.flow.forEach(({ itemId, type }) => {
+          if (type !== 'workflow') return;
+
+          const currentWorkflow = workflows.find(({ id }) => id === itemId);
+
+          if (currentWorkflow) {
+            const engine = new WorkflowEngine(currentWorkflow, {});
+
+            engine.init();
+          }
+        });
+      } else {
+        await workflowState.add(this.id, {
+          state: this.state,
+          isCollection: true,
+          collectionId: this.collection.id,
+        });
+        this._flowHandler(this.collection.flow[0]);
+      }
     } catch (error) {
       console.error(error);
     }

+ 4 - 0
src/background/index.js

@@ -101,6 +101,10 @@ chrome.runtime.onInstalled.addListener((details) => {
 
 const message = new MessageListener('background');
 
+message.on('get:sender', (_, sender) => {
+  return sender;
+});
+
 message.on('collection:execute', executeCollection);
 message.on('collection:stop', (id) => {
   const collection = runningCollections[id];

+ 183 - 116
src/background/workflow-engine/blocks-handler.js

@@ -2,10 +2,11 @@
 import browser from 'webextension-polyfill';
 import { objectHasKey, fileSaver, isObject } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
+import { executeWebhook } from '@/utils/webhookUtil';
+import executeContentScript from '@/utils/execute-content-script';
 import dataExporter, { generateJSON } from '@/utils/data-exporter';
 import compareBlockValue from '@/utils/compare-block-value';
 import errorMessage from './error-message';
-import { executeWebhook } from '@/utils/webhookUtil';
 
 function getBlockConnection(block, index = 1) {
   const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
@@ -67,11 +68,7 @@ export async function trigger(block) {
   const nextBlockId = getBlockConnection(block);
   try {
     if (block.data.type === 'visit-web' && this.tabId) {
-      await browser.tabs.executeScript(this.tabId, {
-        file: './contentScript.bundle.js',
-      });
-
-      this._connectTab(this.tabId);
+      this.frames = executeContentScript(this.tabId, 'trigger');
     }
 
     return { nextBlockId, data: '' };
@@ -187,46 +184,90 @@ export function forwardPage(block) {
   });
 }
 
+export async function newWindow(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const { incognito, windowState } = block.data;
+    const { id } = await browser.windows.create({
+      incognito,
+      state: windowState,
+    });
+
+    this.windowId = id;
+
+    return {
+      data: id,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
 function tabUpdatedListener(tab) {
-  return new Promise((resolve, reject) => {
+  return new Promise((resolve) => {
     this._listener({
       name: 'tab-updated',
       id: tab.id,
-      once: true,
       callback: async (tabId, changeInfo, deleteListener) => {
         if (changeInfo.status !== 'complete') return;
 
-        try {
-          await browser.tabs.executeScript(tabId, {
-            file: './contentScript.bundle.js',
-          });
+        const frames = await executeContentScript(tabId, 'newtab');
 
-          deleteListener();
-          this._connectTab(tabId);
+        deleteListener();
 
-          resolve();
-        } catch (error) {
-          console.error(error);
-          reject(error);
-        }
+        resolve(frames);
       },
     });
   });
 }
 export async function newTab(block) {
+  if (this.windowId) {
+    try {
+      await browser.windows.get(this.windowId);
+    } catch (error) {
+      this.windowId = null;
+    }
+  }
+
   try {
-    const { updatePrevTab, url, active } = block.data;
+    const { updatePrevTab, url, active, inGroup } = block.data;
 
     if (updatePrevTab && this.tabId) {
       await browser.tabs.update(this.tabId, { url, active });
     } else {
-      const { id, windowId } = await browser.tabs.create({ url, active });
+      const tab = await browser.tabs.create({
+        url,
+        active,
+        windowId: this.windowId,
+      });
 
-      this.tabId = id;
-      this.windowId = windowId;
+      this.tabId = tab.id;
+      this.windowId = tab.windowId;
     }
 
-    await tabUpdatedListener.call(this, { id: this.tabId });
+    if (inGroup && !updatePrevTab) {
+      const options = {
+        groupId: this.tabGroupId,
+        tabIds: this.tabId,
+      };
+
+      if (!this.tabGroupId) {
+        options.createProperties = {
+          windowId: this.windowId,
+        };
+      }
+
+      chrome.tabs.group(options, (tabGroupId) => {
+        this.tabGroupId = tabGroupId;
+      });
+    }
+
+    this.frameId = 0;
+    this.frames = await tabUpdatedListener.call(this, { id: this.tabId });
 
     return {
       data: url,
@@ -258,13 +299,11 @@ export async function activeTab(block) {
       currentWindow: true,
     });
 
-    await browser.tabs.executeScript(tab.id, {
-      file: './contentScript.bundle.js',
-    });
+    this.frames = await executeContentScript(tab.id, 'activetab');
 
+    this.frameId = 0;
     this.tabId = tab.id;
     this.windowId = tab.windowId;
-    this._connectTab(tab.id);
 
     return data;
   } catch (error) {
@@ -342,87 +381,113 @@ export async function takeScreenshot(block) {
   }
 }
 
-export function interactionHandler(block) {
-  return new Promise((resolve, reject) => {
-    const nextBlockId = getBlockConnection(block);
+export async function switchTo(block) {
+  const nextBlockId = getBlockConnection(block);
 
-    if (!this.connectedTab) {
-      reject(generateBlockError(block));
+  try {
+    if (block.data.windowType === 'main-window') {
+      this.frameId = 0;
 
-      return;
+      return {
+        data: '',
+        nextBlockId,
+      };
     }
 
-    this.connectedTab.postMessage({ isBlock: true, ...block });
-    this._listener({
-      name: 'tab-message',
-      id: block.name,
-      once: true,
-      delay: block.name === 'link' ? 5000 : 0,
-      callback: (data) => {
-        if (data?.isError) {
-          const error = new Error(data.message);
-          error.nextBlockId = nextBlockId;
-
-          reject(error);
-          return;
-        }
+    const { url } = await this._sendMessageToTab(block, { frameId: 0 });
 
-        const getColumn = (name) =>
-          this.workflow.dataColumns.find((item) => item.name === name) || {
-            name: 'column',
-            type: 'text',
-          };
-        const pushData = (column, value) => {
-          this.data[column.name]?.push(convertData(value, column.type));
-        };
+    if (objectHasKey(this.frames, url)) {
+      this.frameId = this.frames[url];
 
-        if (objectHasKey(block.data, 'dataColumn')) {
-          const column = getColumn(block.data.dataColumn);
-
-          if (block.data.saveData) {
-            if (Array.isArray(data)) {
-              data.forEach((item) => {
-                pushData(column, item);
-              });
-            } else {
-              pushData(column, data);
-            }
-          }
-        } else if (block.name === 'javascript-code') {
-          const memoColumn = {};
-          const pushObjectData = (obj) => {
-            Object.entries(obj).forEach(([key, value]) => {
-              let column;
-
-              if (memoColumn[key]) {
-                column = memoColumn[key];
-              } else {
-                const currentColumn = getColumn(key);
-
-                column = currentColumn;
-                memoColumn[key] = currentColumn;
-              }
-
-              pushData(column, value);
-            });
-          };
-
-          if (Array.isArray(data)) {
-            data.forEach((obj) => {
-              if (isObject(obj)) pushObjectData(obj);
-            });
-          } else if (isObject(data)) {
-            pushObjectData(data);
-          }
+      return {
+        data: this.frameId,
+        nextBlockId,
+      };
+    }
+    throw new Error(errorMessage('no-iframe-id', block.data));
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export async function interactionHandler(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const data = await this._sendMessageToTab(block, {
+      frameId: this.frameId || 0,
+    });
+
+    if (block.name === 'link')
+      await new Promise((resolve) => setTimeout(resolve, 5000));
+
+    if (data?.isError) {
+      const error = new Error(data.message);
+      error.nextBlockId = nextBlockId;
+
+      throw error;
+    }
+
+    const getColumn = (name) =>
+      this.workflow.dataColumns.find((item) => item.name === name) || {
+        name: 'column',
+        type: 'text',
+      };
+    const pushData = (column, value) => {
+      this.data[column.name]?.push(convertData(value, column.type));
+    };
+
+    if (objectHasKey(block.data, 'dataColumn')) {
+      const column = getColumn(block.data.dataColumn);
+
+      if (block.data.saveData) {
+        if (Array.isArray(data)) {
+          data.forEach((item) => {
+            pushData(column, item);
+          });
+        } else {
+          pushData(column, data);
         }
+      }
+    } else if (block.name === 'javascript-code') {
+      const memoColumn = {};
+      const pushObjectData = (obj) => {
+        Object.entries(obj).forEach(([key, value]) => {
+          let column;
+
+          if (memoColumn[key]) {
+            column = memoColumn[key];
+          } else {
+            const currentColumn = getColumn(key);
+
+            column = currentColumn;
+            memoColumn[key] = currentColumn;
+          }
 
-        resolve({
-          data,
-          nextBlockId,
+          pushData(column, value);
         });
-      },
-    });
-  });
+      };
+
+      if (Array.isArray(data)) {
+        data.forEach((obj) => {
+          if (isObject(obj)) pushObjectData(obj);
+        });
+      } else if (isObject(data)) {
+        pushObjectData(data);
+      }
+    }
+
+    return {
+      data,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
 }
 
 export function delay(block) {
@@ -449,24 +514,18 @@ export function exportData(block) {
 
 export function elementExists(block) {
   return new Promise((resolve, reject) => {
-    if (!this.connectedTab) {
-      reject(generateBlockError(block));
-
-      return;
-    }
-
-    this.connectedTab.postMessage({ isBlock: true, ...block });
-    this._listener({
-      name: 'tab-message',
-      id: block.name,
-      once: true,
-      callback: (data) => {
+    this._sendMessageToTab(block)
+      .then((data) => {
         resolve({
           data,
           nextBlockId: getBlockConnection(block, data ? 1 : 2),
         });
-      },
-    });
+      })
+      .catch((error) => {
+        error.nextBlockId = getBlockConnection(block);
+
+        reject(error);
+      });
   });
 }
 
@@ -519,13 +578,21 @@ export function repeatTask({ data, id, outputs }) {
 
 export function webhook({ data, outputs }) {
   return new Promise((resolve, reject) => {
+    const nextBlockId = getBlockConnection({ outputs });
+
     if (!data.url) {
-      reject(new Error('URL is empty'));
+      const error = new Error('URL is empty');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
       return;
     }
 
     if (!data.url.startsWith('http')) {
-      reject(new Error('URL is not valid'));
+      const error = new Error('URL is not valid');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
       return;
     }
 

+ 6 - 3
src/background/workflow-engine/error-message.js

@@ -1,8 +1,11 @@
-import { objectHasKey, replaceMustache } from '@/utils/helper';
+import { get } from 'object-path-immutable';
+import { replaceMustache } from '@/utils/helper';
 
 const messages = {
   'no-trigger-block': '"{{name}}"" workflow doesn\'t have a trigger block.',
   'no-block': '"{{name}}" workflow doesn\'t have any blocks.',
+  'no-iframe-id':
+    'Can\'t find Frame ID for the frame element with "{{selector}}" selector',
   'no-tab':
     'Can\'t connect to a tab, use "New tab" or "Active tab" block before using the "{{name}}" block.',
 };
@@ -12,11 +15,11 @@ export default function (errorId, data) {
 
   if (!message) return `Can't find message for this error (${errorId})`;
 
-  /* eslint-disable-next-line */
   const resultMessage = replaceMustache(message, (match) => {
     const key = match.slice(2, -2);
+    const result = get(data, key);
 
-    return objectHasKey(data, key) ? data[key] : key;
+    return result ?? key;
   });
 
   return resultMessage;

+ 29 - 54
src/background/workflow-engine/index.js

@@ -3,31 +3,17 @@ import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
 import { toCamelCase } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
-import referenceData from '@/utils/reference-data';
 import errorMessage from './error-message';
+import referenceData from '@/utils/reference-data';
 import workflowState from '../workflow-state';
 import * as blocksHandler from './blocks-handler';
+import executeContentScript from '@/utils/execute-content-script';
 
 let reloadTimeout;
 
-function tabMessageHandler({ type, data }) {
-  const listener = this.tabMessageListeners[type];
-
-  if (listener) {
-    setTimeout(() => {
-      listener.callback(data);
-    }, listener.delay || 0);
-
-    if (listener.once) delete this.tabMessageListeners[type];
-  }
-}
 function tabRemovedHandler(tabId) {
   if (tabId !== this.tabId) return;
 
-  this.connectedTab?.onMessage.removeListener(this.tabMessageHandler);
-  this.connectedTab?.disconnect();
-
-  delete this.connectedTab;
   delete this.tabId;
 
   if (tasks[this.currentBlock.name].category === 'interaction') {
@@ -56,13 +42,10 @@ function tabUpdatedHandler(tabId, changeInfo) {
       clearTimeout(reloadTimeout);
       reloadTimeout = null;
 
-      browser.tabs
-        .executeScript(tabId, {
-          file: './contentScript.bundle.js',
-        })
-        .then(() => {
-          if (this.connectedTab) this._connectTab(this.tabId);
-
+      executeContentScript(tabId, 'update tab')
+        .then((frames) => {
+          this.tabId = tabId;
+          this.frames = frames;
           this.isPaused = false;
         })
         .catch((error) => {
@@ -81,21 +64,22 @@ class WorkflowEngine {
     this.isInCollection = isInCollection;
     this.collectionLogId = collectionLogId;
     this.data = {};
+    this.logs = [];
     this.blocks = {};
-    this.eventListeners = {};
-    this.repeatedTasks = {};
+    this.frames = {};
     this.loopList = {};
     this.loopData = {};
-    this.logs = [];
+    this.repeatedTasks = {};
+    this.eventListeners = {};
     this.isPaused = false;
     this.isDestroyed = false;
+    this.frameId = null;
+    this.windowId = null;
+    this.tabGroupId = null;
     this.currentBlock = null;
     this.workflowTimeout = null;
-    this.windowId = null;
 
-    this.tabMessageListeners = {};
     this.tabUpdatedListeners = {};
-    this.tabMessageHandler = tabMessageHandler.bind(this);
     this.tabUpdatedHandler = tabUpdatedHandler.bind(this);
     this.tabRemovedHandler = tabRemovedHandler.bind(this);
   }
@@ -176,7 +160,6 @@ class WorkflowEngine {
       this.dispatchEvent('destroyed', { id: this.id, status, message });
 
       this.eventListeners = {};
-      this.tabMessageListeners = {};
       this.tabUpdatedListeners = {};
 
       await browser.tabs.onRemoved.removeListener(this.tabRemovedHandler);
@@ -268,11 +251,11 @@ class WorkflowEngine {
     this.dispatchEvent('update', this.state);
 
     const started = Date.now();
-    const isInteraction = tasks[block.name].category === 'interaction';
-    const handlerName = isInteraction
-      ? 'interactionHandler'
-      : toCamelCase(block?.name);
-    const handler = blocksHandler[handlerName];
+    const blockHandler = blocksHandler[toCamelCase(block?.name)];
+    const handler =
+      !blockHandler && tasks[block.name].category === 'interaction'
+        ? blocksHandler.interactionHandler
+        : blockHandler;
 
     if (handler) {
       const replacedBlock = referenceData(block, {
@@ -335,35 +318,27 @@ class WorkflowEngine {
     }
   }
 
-  _connectTab(tabId) {
-    const connectedTab = browser.tabs.connect(tabId, {
-      name: `${this.workflow.id}--${this.workflow.name.slice(0, 10)}`,
-    });
-
-    if (this.connectedTab) {
-      this.connectedTab.onMessage.removeListener(this.tabMessageHandler);
-      this.connectedTab.disconnect();
-    }
-
-    connectedTab.onMessage.addListener(this.tabMessageHandler);
+  _sendMessageToTab(block, options = {}) {
+    return new Promise((resolve, reject) => {
+      if (!this.tabId) {
+        const message = errorMessage('no-tab', tasks[block.name]);
 
-    this.connectedTab = connectedTab;
-    this.tabId = tabId;
+        reject(new Error(message));
+      }
 
-    return connectedTab;
+      browser.tabs
+        .sendMessage(this.tabId, { isBlock: true, ...block }, options)
+        .then(resolve)
+        .catch(reject);
+    });
   }
 
   _listener({ id, name, callback, once = true, ...options }) {
     const listenerNames = {
       event: 'eventListener',
       'tab-updated': 'tabUpdatedListeners',
-      'tab-message': 'tabMessageListeners',
     };
     this[listenerNames[name]][id] = { callback, once, ...options };
-
-    return () => {
-      delete this.tabMessageListeners[id];
-    };
   }
 }
 

+ 11 - 5
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -1,9 +1,8 @@
-<!-- use the current active tab optoin?  -->
 <template>
-  <div class="mb-2 mt-4">
+  <div class="mb-2 mt-4 space-y-2">
     <ui-textarea
       :model-value="data.description"
-      class="w-full mb-2"
+      class="w-full"
       placeholder="Description"
       @change="updateData({ description: $event })"
     />
@@ -18,14 +17,15 @@
     <a
       href="https://github.com/Kholid060/automa/wiki/Features#reference-data"
       rel="noopener"
-      class="text-primary inline-block mb-2 text-sm"
+      class="text-primary inline-block text-sm"
       target="_blank"
+      style="margin-top: 0"
     >
       Learn how to add dynamic data
     </a>
     <ui-checkbox
       :model-value="data.updatePrevTab"
-      class="mb-2 leading-tight"
+      class="leading-tight"
       title="Use the previously opened new tab instead of creating a new one"
       @change="updateData({ updatePrevTab: $event })"
     >
@@ -37,6 +37,12 @@
     >
       Set as active tab
     </ui-checkbox>
+    <ui-checkbox
+      :model-value="data.inGroup"
+      @change="updateData({ inGroup: $event })"
+    >
+      Add tab to group
+    </ui-checkbox>
   </div>
 </template>
 <script setup>

+ 59 - 0
src/components/newtab/workflow/edit/EditNewWindow.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="mb-2 mt-4 space-y-2">
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      placeholder="Description"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :model-value="data.windowState"
+      class="w-full"
+      placeholder="Window state"
+      @change="updateData({ windowState: $event })"
+    >
+      <option v-for="state in windowStates" :key="state" :value="state">
+        {{ state }}
+      </option>
+    </ui-select>
+    <ui-checkbox
+      :model-value="data.incognito"
+      :disabled="!allowInIncognito"
+      @change="updateData({ incognito: $event })"
+    >
+      Set as incognito window
+      <span
+        title="You must enable 'Allow in incognito' for this extension to use the option"
+      >
+        &#128712;
+      </span>
+    </ui-checkbox>
+  </div>
+</template>
+<script setup>
+import { ref, onMounted } from 'vue';
+import browser from 'webextension-polyfill';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const windowStates = ['normal', 'minimized', 'maximized', 'fullscreen'];
+const allowInIncognito = ref(false);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+onMounted(async () => {
+  allowInIncognito.value = await browser.extension.isAllowedIncognitoAccess();
+
+  if (!allowInIncognito.value) {
+    updateData({ incognito: false });
+  }
+});
+</script>

+ 39 - 0
src/components/newtab/workflow/edit/EditSwitchTo.vue

@@ -0,0 +1,39 @@
+<template>
+  <div class="space-y-2">
+    <ui-textarea
+      :model-value="data.description"
+      autoresize
+      placeholder="Description"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :model-value="data.windowType"
+      class="w-full"
+      @change="updateData({ windowType: $event })"
+    >
+      <option value="main-window">Main window</option>
+      <option value="iframe">Iframe</option>
+    </ui-select>
+    <ui-input
+      v-if="data.windowType === 'iframe'"
+      :model-value="data.selector"
+      placeholder="Iframe element selector"
+      class="mb-1 w-full"
+      @change="updateData({ selector: $event })"
+    />
+  </div>
+</template>
+<script setup>
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 21 - 3
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -11,15 +11,23 @@
       </option>
     </ui-select>
     <button
-      class="mb-2 block w-full text-left mt-4 focus:ring-0"
+      class="mb-2 block flex items-center w-full text-left mt-4 focus:ring-0"
       @click="showOptions = !showOptions"
     >
       <v-remixicon
         name="riArrowLeftSLine"
-        class="mr-1 transition-transform align-middle inline-block -ml-1"
+        class="mr-1 transition-transform -ml-1"
         :rotate="showOptions ? 270 : 180"
       />
-      <span class="align-middle">Options</span>
+      <span class="flex-1">Options</span>
+      <a
+        :href="getEventDetailsUrl()"
+        rel="noopener"
+        target="_blank"
+        @click.stop
+      >
+        <v-remixicon name="riInformationLine" size="20" />
+      </a>
     </button>
     <transition-expand>
       <div v-if="showOptions">
@@ -51,6 +59,7 @@
 import TriggerEventMouse from './TriggerEventMouse.vue';
 import TriggerEventTouch from './TriggerEventTouch.vue';
 import TriggerEventWheel from './TriggerEventWheel.vue';
+import TriggerEventInput from './TriggerEventInput.vue';
 import TriggerEventKeyboard from './TriggerEventKeyboard.vue';
 
 export default {
@@ -58,6 +67,7 @@ export default {
     TriggerEventMouse,
     TriggerEventWheel,
     TriggerEventTouch,
+    TriggerEventInput,
     TriggerEventKeyboard,
   },
 };
@@ -66,6 +76,7 @@ export default {
 /* eslint-disable */
 import { ref } from 'vue';
 import { eventList } from '@/utils/shared';
+import { toCamelCase } from '@/utils/helper';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -79,15 +90,22 @@ const emit = defineEmits(['update:data']);
 const eventComponents = {
   'mouse-event': 'TriggerEventMouse',
   'focus-event': '',
+  'event': '',
   'touch-event': 'TriggerEventTouch',
   'keyboard-event': 'TriggerEventKeyboard',
   'wheel-event': 'TriggerEventWheel',
+  'input-event': 'TriggerEventInput',
 };
 
 const componentName = ref(eventComponents[props.data.eventType]);
 const params = ref(props.data.eventParams);
 const showOptions = ref(false);
 
+function getEventDetailsUrl() {
+  const eventType = toCamelCase(props.data.eventType);
+
+  return `https://developer.mozilla.org/en-US/docs/Web/API/${eventType}/${eventType}`;
+}
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 37 - 0
src/components/newtab/workflow/edit/TriggerEventInput.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="grid gap-2 grid-cols-2">
+    <ui-input v-model="defaultParams.data" label="Data" />
+    <ui-input v-model="defaultParams.inputType" label="Input type" />
+  </div>
+</template>
+<script setup>
+import { shallowReactive, watch, onMounted } from 'vue';
+import { objectHasKey } from '@/utils/helper';
+
+const props = defineProps({
+  params: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const defaultParams = shallowReactive({
+  data: '',
+  inputType: 'insertText',
+});
+
+watch(
+  defaultParams,
+  (value) => {
+    emit('update', value);
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.entries(props.params).forEach(([key, value]) => {
+    if (objectHasKey(defaultParams, key)) defaultParams[key] = value;
+  });
+});
+</script>

+ 6 - 1
src/components/ui/uiCheckbox.vue

@@ -1,6 +1,7 @@
 <template>
   <label class="checkbox-ui inline-flex items-center">
     <div
+      :class="{ 'pointer-events-none opacity-75': disabled }"
       class="
         relative
         h-5
@@ -14,7 +15,7 @@
         type="checkbox"
         class="opacity-0 checkbox-ui__input"
         :value="modelValue"
-        v-bind="{ checked: modelValue }"
+        v-bind="{ checked: modelValue, disabled }"
         @change="changeHandler"
       />
       <div
@@ -48,6 +49,10 @@ export default {
       type: Boolean,
       default: false,
     },
+    disabled: {
+      type: Boolean,
+      default: null,
+    },
   },
   emits: ['update:modelValue', 'change'],
   setup(props, { emit }) {

+ 24 - 1
src/content/blocks-handler.js

@@ -7,7 +7,7 @@ function markElement(el, { id, data }) {
     el.setAttribute(`block--${id}`, '');
   }
 }
-function handleElement({ data, id }, callback) {
+function handleElement({ data, id }, callback, errCallback) {
   if (!data || !data.selector) return null;
 
   try {
@@ -30,12 +30,33 @@ function handleElement({ data, id }, callback) {
     } else if (element) {
       markElement(element, { id, data });
       callback(element);
+    } else if (errCallback) {
+      errCallback();
     }
   } catch (error) {
     console.error(error);
   }
 }
 
+export function switchTo(block) {
+  return new Promise((resolve) => {
+    handleElement(
+      block,
+      (element) => {
+        if (element.tagName !== 'IFRAME') {
+          resolve('');
+          return;
+        }
+
+        resolve({ url: element.src });
+      },
+      () => {
+        resolve('');
+      }
+    );
+  });
+}
+
 export function eventClick(block) {
   return new Promise((resolve) => {
     handleElement(block, (element) => {
@@ -212,6 +233,8 @@ export function link(block) {
       return;
     }
 
+    markElement(element, block);
+
     const url = element.href;
 
     if (url) window.location.href = url;

+ 78 - 37
src/content/element-selector/ElementSelector.ce.vue

@@ -1,23 +1,33 @@
 <template>
-  <div
-    :style="{
-      transform: `translate(${element.hovered.x}px, ${element.hovered.y}px)`,
-      height: element.hovered.height + 'px',
-      width: element.hovered.width + 'px',
-    }"
-    class="indicator pointer-events-auto"
-  ></div>
-  <div
-    v-if="element.selector"
-    :style="{
-      transform: `translate(${element.selected.x}px, ${element.selected.y}px)`,
-      height: element.selected.height + 'px',
-      width: element.selected.width + 'px',
-      zIndex: 99,
-    }"
-    class="indicator selected"
-  ></div>
+  <template v-if="!element.hide">
+    <div class="overlay"></div>
+    <div
+      :style="{
+        transform: `translate(${element.hovered.x}px, ${element.hovered.y}px)`,
+        height: element.hovered.height + 'px',
+        width: element.hovered.width + 'px',
+      }"
+      class="indicator pointer-events-auto"
+    ></div>
+    <div
+      v-if="element.selector"
+      :style="{
+        transform: `translate(${element.selected.x}px, ${element.selected.y}px)`,
+        height: element.selected.height + 'px',
+        width: element.selected.width + 'px',
+        zIndex: 99,
+      }"
+      class="indicator selected"
+    ></div>
+  </template>
   <div class="card">
+    <button
+      title="Toggle hide"
+      class="mr-2"
+      @click="element.hide = !element.hide"
+    >
+      <v-remix-icon :path="element.hide ? riEyeLine : riEyeOffLine" />
+    </button>
     <div class="selector">
       <v-remix-icon
         style="cursor: pointer"
@@ -33,32 +43,40 @@
         :value="element.selector"
       />
     </div>
-    <template v-if="element.selector">
+    <template v-if="element.selector && !element.hide">
       <button
         title="Select parent element (press P)"
+        class="ml-2"
         @click="selectParentElement"
       >
         <v-remix-icon :path="riArrowDownLine" rotate="180" />
       </button>
       <button
         title="Select parent element (press C)"
+        class="ml-2"
         @click="selectChildElement"
       >
         <v-remix-icon :path="riArrowDownLine" />
       </button>
     </template>
-    <button class="primary" @click="destroy">Close</button>
+    <button class="primary ml-2" @click="destroy">Close</button>
   </div>
 </template>
 <script setup>
 import { reactive } from 'vue';
 import { finder } from '@medv/finder';
 import { VRemixIcon } from 'v-remixicon';
-import { riFileCopyLine, riArrowDownLine } from 'v-remixicon/icons';
+import {
+  riFileCopyLine,
+  riArrowDownLine,
+  riEyeLine,
+  riEyeOffLine,
+} from 'v-remixicon/icons';
 
 /* to-do get list of attribute value */
 
 const element = reactive({
+  hide: window.self !== window.top,
   hovered: {},
   selected: {},
   selector: '',
@@ -83,24 +101,22 @@ function getElementRect(target) {
   };
 }
 function handleMouseMove({ target }) {
-  if (targetEl === target || target === root) return;
+  if (element.hide || targetEl === target || target === root) return;
 
   targetEl = target;
 
   element.hovered = getElementRect(target);
 }
 function copySelector() {
-  navigator.clipboard
-    .writeText(element.selector)
-    .then(() => {
-      root.shadowRoot.querySelector('input')?.select();
-    })
-    .catch((error) => {
-      console.error(error);
-    });
+  root.shadowRoot.querySelector('input')?.select();
+
+  navigator.clipboard.writeText(element.selector).catch((error) => {
+    document.execCommand('copy');
+    console.error(error);
+  });
 }
 function selectChildElement() {
-  if (selectedPath.length === 0) return;
+  if (selectedPath.length === 0 || element.hide) return;
 
   const currentEl = selectedPath[pathIndex];
   let activeEl = currentEl;
@@ -124,7 +140,12 @@ function selectChildElement() {
   selectedEl = activeEl;
 }
 function selectParentElement() {
-  if (selectedEl.tagName === 'HTML' || selectedPath.length === 0) return;
+  if (
+    selectedEl.tagName === 'HTML' ||
+    selectedPath.length === 0 ||
+    element.hide
+  )
+    return;
 
   pathIndex += 1;
   const activeEl = selectedPath[pathIndex];
@@ -134,10 +155,12 @@ function selectParentElement() {
   selectedEl = activeEl;
 }
 function handleClick(event) {
-  if (event.target === root) return;
+  if (event.target === root || element.hide) return;
 
-  event.preventDefault();
-  event.stopPropagation();
+  if (!element.hide) {
+    event.preventDefault();
+    event.stopPropagation();
+  }
 
   selectedPath = event.path;
   element.selected = getElementRect(targetEl);
@@ -185,7 +208,6 @@ window.addEventListener('mousemove', handleMouseMove);
   width: 100%;
   top: 0;
   left: 0;
-  background-color: rgba(0, 0, 0, 0.2);
   pointer-events: none;
   z-index: 99999;
   color: #18181b;
@@ -215,7 +237,6 @@ button {
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-left: 6px;
   cursor: pointer;
 }
 button.primary {
@@ -230,6 +251,25 @@ button.primary {
   padding-left: 12px;
   background-color: #e4e4e7;
 }
+
+.ml-2 {
+  margin-left: 6px;
+}
+
+.mr-2 {
+  margin-right: 6px;
+}
+
+.overlay {
+  background-color: rgba(0, 0, 0, 0.2);
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+}
+
 input {
   border: none;
   color: inherit;
@@ -251,6 +291,7 @@ input:focus {
   border-radius: 8px;
   padding: 12px;
   color: #1f2937;
+  border: 1px solid #e4e4e7;
   pointer-events: all;
   z-index: 999;
 }

+ 20 - 29
src/content/index.js

@@ -3,39 +3,30 @@ import { toCamelCase } from '@/utils/helper';
 import elementSelector from './element-selector';
 import * as blocksHandler from './blocks-handler';
 
-function onConnectListener() {
-  browser.runtime.onConnect.addListener((port) => {
-    port.onMessage.addListener((data) => {
+(() => {
+  browser.runtime.onMessage.addListener((data) => {
+    if (data.isBlock) {
       const handler = blocksHandler[toCamelCase(data.name)];
 
       if (handler) {
-        handler(data)
-          .then((result) => {
-            port.postMessage({ type: data.name, data: result });
-          })
-          .catch((error) => {
-            port.postMessage({
-              isError: true,
-              message: error?.message || error,
-            });
-          });
-      } else {
-        console.error(`"${data.name}" doesn't have a handler`);
+        return handler(data);
       }
-    });
-  });
-}
+      console.error(`"${data.name}" doesn't have a handler`);
 
-browser.runtime.onMessage.addListener(({ type }) => {
-  return new Promise((resolve) => {
-    if (type === 'content-script-exists') {
-      resolve(true);
-    } else if (type === 'select-element') {
-      elementSelector();
-      resolve(true);
+      return Promise.resolve('');
     }
-  });
-});
 
-if (document.readyState === 'complete') onConnectListener();
-else window.addEventListener('load', onConnectListener);
+    return new Promise((resolve) => {
+      if (data.type === 'content-script-exists') {
+        resolve(true);
+      } else if (data.type === 'select-element') {
+        elementSelector();
+        resolve(true);
+      } else if (data.type === 'give-me-the-frame-id') {
+        browser.runtime.sendMessage({
+          type: 'this-is-the-frame-id',
+        });
+      }
+    });
+  });
+})();

+ 6 - 0
src/lib/v-remixicon.js

@@ -2,6 +2,9 @@ import vRemixicon from 'v-remixicon';
 import {
   riHome5Line,
   riFolderLine,
+  riInformationLine,
+  riWindow2Line,
+  riArrowUpDownLine,
   riRefreshLine,
   riBookOpenLine,
   riGithubFill,
@@ -70,6 +73,9 @@ import {
 export const icons = {
   riHome5Line,
   riFolderLine,
+  riInformationLine,
+  riWindow2Line,
+  riArrowUpDownLine,
   riRefreshLine,
   riBookOpenLine,
   riGithubFill,

+ 3 - 0
src/models/collection.js

@@ -14,6 +14,9 @@ class Collection extends Model {
       name: this.string(''),
       flow: this.attr([]),
       createdAt: this.number(),
+      options: this.attr({
+        atOnce: false,
+      }),
     };
   }
 

+ 49 - 2
src/newtab/pages/collections/[id].vue

@@ -81,6 +81,7 @@
                 {{ runningCollection.length }}
               </span>
             </ui-tab>
+            <ui-tab value="options">Options</ui-tab>
           </ui-tabs>
         </div>
         <ui-tab-panels v-model="state.activeTab">
@@ -111,7 +112,25 @@
               @update:modelValue="updateCollectionFlow"
             >
               <template #item="{ element, index }">
-                <ui-card class="group flex cursor-move mb-2 items-center">
+                <ui-card
+                  class="
+                    group
+                    flex
+                    cursor-move
+                    mb-2
+                    items-center
+                    relative
+                    overflow-hidden
+                  "
+                >
+                  <span
+                    :class="[
+                      element.type === 'block'
+                        ? 'bg-yellow-200'
+                        : 'bg-green-200',
+                    ]"
+                    class="absolute w-2 left-0 top-0 h-full"
+                  ></span>
                   <v-remixicon :name="element.icon" class="mr-4" />
                   <p class="flex-1 text-overflow">{{ element.name }}</p>
                   <router-link
@@ -167,13 +186,23 @@
               />
             </div>
           </ui-tab-panel>
+          <ui-tab-panel value="options">
+            <ui-checkbox v-model="collectionOptions.atOnce">
+              <p class="leading-tight">
+                Execute all workflows in the collection at once
+              </p>
+              <p class="text-sm text-gray-600 leading-tight">
+                Block not gonna executed when using this option
+              </p>
+            </ui-checkbox>
+          </ui-tab-panel>
         </ui-tab-panels>
       </div>
     </div>
   </div>
 </template>
 <script setup>
-import { computed, shallowReactive, onMounted } from 'vue';
+import { computed, shallowReactive, onMounted, watch } from 'vue';
 import { nanoid } from 'nanoid';
 import { useStore } from 'vuex';
 import { useRoute, useRouter } from 'vue-router';
@@ -213,6 +242,9 @@ const state = shallowReactive({
   activeTab: 'flow',
   sidebarTab: 'workflows',
 });
+const collectionOptions = shallowReactive({
+  atOnce: false,
+});
 
 const runningCollection = computed(() =>
   store.state.workflowState.filter(
@@ -293,7 +325,22 @@ function deleteCollection() {
   });
 }
 
+watch(
+  () => collectionOptions,
+  (value) => {
+    Collection.update({
+      where: route.params.id,
+      data: {
+        options: value,
+      },
+    });
+  },
+  { deep: true }
+);
+
 onMounted(() => {
+  Object.assign(collectionOptions, collection.value.options);
+
   collectionFlow.value.forEach((item, index) => {
     if (!item.itemId && item.type === 'workflow') {
       deleteCollectionFlow(index);

+ 1 - 0
src/popup/pages/Home.vue

@@ -132,6 +132,7 @@ async function selectElement() {
   } catch (error) {
     if (error.message.includes('Could not establish connection.')) {
       await browser.tabs.executeScript(tab.id, {
+        allFrames: true,
         file: './contentScript.bundle.js',
       });
 

+ 62 - 0
src/utils/execute-content-script.js

@@ -0,0 +1,62 @@
+import browser from 'webextension-polyfill';
+
+function getFrames(tabId) {
+  return new Promise((resolve) => {
+    const frames = {};
+    let frameTimeout;
+    let timeout;
+
+    const onMessageListener = (_, sender) => {
+      if (sender.frameId !== 0) frames[sender.url] = sender.frameId;
+
+      clearTimeout(frameTimeout);
+      frameTimeout = setTimeout(() => {
+        clearTimeout(timeout);
+        browser.runtime.onMessage.removeListener(onMessageListener);
+        resolve(frames);
+      }, 250);
+    };
+
+    browser.tabs.sendMessage(tabId, {
+      type: 'give-me-the-frame-id',
+    });
+    browser.runtime.onMessage.addListener(onMessageListener);
+
+    timeout = setTimeout(() => {
+      clearTimeout(frameTimeout);
+      resolve(frames);
+    }, 5000);
+  });
+}
+
+async function contentScriptExist(tabId) {
+  try {
+    await browser.tabs.sendMessage(tabId, { type: 'content-script-exists' });
+
+    return true;
+  } catch (error) {
+    return false;
+  }
+}
+
+export default async function (tabId) {
+  try {
+    const isScriptExists = await contentScriptExist(tabId);
+
+    if (!isScriptExists) {
+      await browser.tabs.executeScript(tabId, {
+        file: './contentScript.bundle.js',
+        allFrames: true,
+      });
+    }
+
+    await new Promise((resolve) => setTimeout(resolve, 1000));
+
+    const frames = await getFrames(tabId);
+
+    return frames;
+  } catch (error) {
+    console.error(error);
+    return {};
+  }
+}

+ 6 - 8
src/utils/handle-form-element.js

@@ -21,17 +21,15 @@ function formEvent(element, data) {
     });
   }
 
+  simulateEvent(element, 'input', {
+    inputType: 'insertText',
+    data: data.value,
+    bubbles: true,
+    cancelable: true,
+  });
   element.dispatchEvent(
     new Event('change', { bubbles: true, cancelable: true })
   );
-  element.dispatchEvent(
-    new InputEvent('input', {
-      inputType: 'insertText',
-      data: data.value,
-      bubbles: true,
-      cancelable: true,
-    })
-  );
 }
 function inputText({ data, element, index = 0, callback }) {
   const noDelay = data.delay === 0;

+ 0 - 0
src/utils/keyboard-shortcut.js


+ 3 - 0
src/utils/reference-data.js

@@ -41,6 +41,9 @@ export default function (block, data) {
       replacedBlock.data[blockDataKey],
       (match) => {
         const key = match.slice(2, -2).replace(/\s/g, '');
+
+        if (!key) return '';
+
         const { dataKey, path } = parseKey(key);
 
         if (

+ 37 - 1
src/utils/shared.js

@@ -27,7 +27,7 @@ export const tasks = {
   },
   'active-tab': {
     name: 'Active tab',
-    description: 'Set as active tab',
+    description: "Set current tab that you're in as a active tab",
     icon: 'riWindowLine',
     component: 'BlockBasic',
     category: 'browser',
@@ -53,9 +53,27 @@ export const tasks = {
       description: '',
       url: '',
       active: true,
+      inGroup: false,
       updatePrevTab: false,
     },
   },
+  'new-window': {
+    name: 'New window',
+    description: 'Create a new window',
+    icon: 'riWindow2Line',
+    component: 'BlockBasic',
+    editComponent: 'EditNewWindow',
+    category: 'browser',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      description: '',
+      incognito: false,
+      windowState: 'normal',
+    },
+  },
   'go-back': {
     name: 'Go back',
     description: 'Go back to the previous page',
@@ -421,6 +439,22 @@ export const tasks = {
       loopId: '',
     },
   },
+  'switch-to': {
+    name: 'Switch frame',
+    description: 'Switch between main window and iframe',
+    icon: 'riArrowUpDownLine',
+    component: 'BlockBasic',
+    editComponent: 'EditSwitchTo',
+    category: 'interaction',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      selector: '',
+      windowType: 'main-window',
+    },
+  },
 };
 
 export const categories = {
@@ -449,6 +483,8 @@ export const eventList = [
   { id: 'mousedown', name: 'Mousedown', type: 'mouse-event' },
   { id: 'focus', name: 'Focus', type: 'focus-event' },
   { id: 'blur', name: 'Blur', type: 'focus-event' },
+  { id: 'input', name: 'Input', type: 'input-event' },
+  { id: 'change', name: 'Change', type: 'event' },
   { id: 'touchstart', name: 'Touch start', type: 'touch-event' },
   { id: 'touchend', name: 'Touch end', type: 'touch-event' },
   { id: 'touchmove', name: 'Touch move', type: 'touch-event' },

+ 3 - 0
src/utils/simulate-event.js

@@ -20,6 +20,9 @@ export function getEventObj(name, params) {
     case 'wheel-event':
       event = new WheelEvent(name, params);
       break;
+    case 'input-event':
+      event = new InputEvent(name, params);
+      break;
     default:
       event = new Event(name, params);
   }

+ 7 - 8
src/utils/webhookUtil.js

@@ -8,14 +8,13 @@ const renderContent = (content, contentType) => {
 
   if (contentType === 'form') {
     return Object.keys(renderedJson)
-      .map(
-        (key) =>
-          `${key}=${
-            isObject(renderedJson[key])
-              ? JSON.stringify(renderedJson[key])
-              : renderedJson[key]
-          }`
-      )
+      .map((key) => {
+        const value = isObject(renderedJson[key])
+          ? JSON.stringify(renderedJson[key])
+          : renderedJson[key];
+
+        return `${key}=${value}`;
+      })
       .join('&');
   }