Ver Fonte

v1.22.0

Ahmad Kholid há 2 anos atrás
pai
commit
0aeba44a9b
100 ficheiros alterados com 2941 adições e 1806 exclusões
  1. 11 10
      package.json
  2. 4 5
      src/assets/css/flow.css
  3. 69 0
      src/background/BackgroundEventsListeners.js
  4. 56 0
      src/background/BackgroundUtils.js
  5. 202 0
      src/background/BackgroundWorkflowTriggers.js
  6. 56 0
      src/background/BackgroundWorkflowUtils.js
  7. 68 604
      src/background/index.js
  8. 0 109
      src/background/workflowEngine/blocksHandler/handlerConditions.js
  9. 0 38
      src/background/workflowEngine/blocksHandler/handlerCreateElement.js
  10. 0 58
      src/background/workflowEngine/blocksHandler/handlerJavascriptCode.js
  11. 90 36
      src/components/block/BlockBase.vue
  12. 10 27
      src/components/block/BlockBasic.vue
  13. 7 27
      src/components/block/BlockBasicWithFallback.vue
  14. 7 2
      src/components/block/BlockConditions.vue
  15. 15 5
      src/components/block/BlockDelay.vue
  16. 7 3
      src/components/block/BlockElementExists.vue
  17. 16 24
      src/components/block/BlockGroup.vue
  18. 15 5
      src/components/block/BlockLoopBreakpoint.vue
  19. 19 7
      src/components/block/BlockPackage.vue
  20. 15 11
      src/components/block/BlockRepeatTask.vue
  21. 3 1
      src/components/content/selector/SelectorElementsDetail.vue
  22. 2 2
      src/components/content/shared/SharedElementHighlighter.vue
  23. 24 0
      src/components/newtab/app/AppSidebar.vue
  24. 5 5
      src/components/newtab/logs/LogsFilters.vue
  25. 16 8
      src/components/newtab/logs/LogsHistory.vue
  26. 40 6
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  27. 63 0
      src/components/newtab/shared/SharedElSelectorActions.vue
  28. 10 6
      src/components/newtab/shared/SharedLogsTable.vue
  29. 2 6
      src/components/newtab/shared/SharedWorkflowState.vue
  30. 6 1
      src/components/newtab/storage/StorageCredentials.vue
  31. 45 31
      src/components/newtab/storage/StorageTables.vue
  32. 6 1
      src/components/newtab/storage/StorageVariables.vue
  33. 1 1
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  34. 2 21
      src/components/newtab/workflow/WorkflowEditBlock.vue
  35. 44 9
      src/components/newtab/workflow/WorkflowEditor.vue
  36. 2 2
      src/components/newtab/workflow/WorkflowRunning.vue
  37. 29 56
      src/components/newtab/workflow/edit/EditBlockSettings.vue
  38. 40 44
      src/components/newtab/workflow/edit/EditConditions.vue
  39. 19 11
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  40. 50 25
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  41. 8 2
      src/components/newtab/workflow/edit/EditLoopData.vue
  42. 20 7
      src/components/newtab/workflow/edit/EditLoopElements.vue
  43. 7 2
      src/components/newtab/workflow/edit/EditPressKey.vue
  44. 7 1
      src/components/newtab/workflow/edit/EditSwitchTo.vue
  45. 1 1
      src/components/newtab/workflow/edit/EditWebhook.vue
  46. 9 0
      src/components/newtab/workflow/edit/EditWorkflowParameters.vue
  47. 28 0
      src/components/newtab/workflow/edit/Parameter/ParameterJsonValue.vue
  48. 1 1
      src/components/newtab/workflow/editor/EditorCustomEdge.vue
  49. 53 17
      src/components/newtab/workflow/editor/EditorLocalActions.vue
  50. 17 10
      src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue
  51. 1 4
      src/components/newtab/workflows/WorkflowsHosted.vue
  52. 17 4
      src/components/newtab/workflows/WorkflowsLocal.vue
  53. 1 5
      src/components/newtab/workflows/WorkflowsShared.vue
  54. 1 4
      src/components/newtab/workflows/WorkflowsUserTeam.vue
  55. 1 3
      src/components/popup/home/HomeTeamWorkflows.vue
  56. 5 5
      src/content/blocksHandler/handlerConditions.js
  57. 12 35
      src/content/blocksHandler/handlerCreateElement.js
  58. 20 139
      src/content/blocksHandler/handlerJavascriptCode.js
  59. 3 1
      src/content/blocksHandler/handlerTakeScreenshot.js
  60. 1 1
      src/content/blocksHandler/handlerUploadFile.js
  61. 60 0
      src/content/blocksHandler/handlerVerifySelector.js
  62. 116 57
      src/content/commandPalette/App.vue
  63. 3 1
      src/content/commandPalette/compsUi.js
  64. 4 0
      src/content/commandPalette/icons.js
  65. 45 11
      src/content/elementSelector/App.vue
  66. 1 0
      src/content/handleSelector.js
  67. 29 2
      src/content/index.js
  68. 11 3
      src/content/services/recordWorkflow/recordEvents.js
  69. 5 1
      src/content/services/webService.js
  70. 4 0
      src/lib/vRemixicon.js
  71. 1 0
      src/lib/vueI18n.js
  72. 11 0
      src/locales/en/blocks.json
  73. 27 16
      src/manifest.chrome.json
  74. 2 5
      src/manifest.firefox.json
  75. 101 22
      src/newtab/App.vue
  76. 1 0
      src/newtab/index.html
  77. 1 0
      src/newtab/index.js
  78. 2 1
      src/newtab/pages/Logs.vue
  79. 23 10
      src/newtab/pages/Packages.vue
  80. 275 0
      src/newtab/pages/Recording.vue
  81. 52 44
      src/newtab/pages/ScheduledWorkflow.vue
  82. 16 1
      src/newtab/pages/Settings.vue
  83. 1 1
      src/newtab/pages/Storage.vue
  84. 129 36
      src/newtab/pages/Workflows.vue
  85. 2 2
      src/newtab/pages/logs/Running.vue
  86. 23 21
      src/newtab/pages/storage/Tables.vue
  87. 5 5
      src/newtab/pages/workflows/Host.vue
  88. 80 33
      src/newtab/pages/workflows/[id].vue
  89. 6 0
      src/newtab/router.js
  90. 131 0
      src/newtab/utils/RecordWorkflowUtils.js
  91. 125 0
      src/newtab/utils/elementSelector.js
  92. 155 0
      src/newtab/utils/javascriptBlockUtil.js
  93. 66 0
      src/newtab/utils/startRecordWorkflow.js
  94. 80 55
      src/newtab/workflowEngine/WorkflowEngine.js
  95. 0 4
      src/newtab/workflowEngine/WorkflowLogger.js
  96. 0 0
      src/newtab/workflowEngine/WorkflowState.js
  97. 16 11
      src/newtab/workflowEngine/WorkflowWorker.js
  98. 12 5
      src/newtab/workflowEngine/blocksHandler.js
  99. 24 6
      src/newtab/workflowEngine/blocksHandler/handlerActiveTab.js
  100. 7 4
      src/newtab/workflowEngine/blocksHandler/handlerBlockPackage.js

+ 11 - 10
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.21.6",
+  "version": "1.22.0",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",
@@ -28,7 +28,6 @@
     "*.{js,ts,vue}": "eslint --fix"
   },
   "dependencies": {
-    "@braks/vue-flow": "^0.4.40",
     "@codemirror/autocomplete": "^6.1.0",
     "@codemirror/lang-css": "^6.0.0",
     "@codemirror/lang-html": "^6.1.2",
@@ -37,14 +36,17 @@
     "@codemirror/language": "^6.2.1",
     "@codemirror/theme-one-dark": "^6.0.0",
     "@medv/finder": "^2.1.0",
+    "@n8n_io/riot-tmpl": "^1.0.1",
     "@tiptap/extension-character-count": "^2.0.0-beta.31",
     "@tiptap/extension-image": "^2.0.0-beta.30",
     "@tiptap/extension-link": "^2.0.0-beta.43",
     "@tiptap/extension-placeholder": "^2.0.0-beta.53",
     "@tiptap/starter-kit": "^2.0.0-beta.197",
     "@tiptap/vue-3": "^2.0.0-beta.96",
-    "@viselect/vanilla": "^3.1.1",
-    "@vueuse/head": "^0.9.8",
+    "@viselect/vanilla": "^3.1.0",
+    "@vue-flow/additional-components": "1.0.0",
+    "@vue-flow/core": "1.0.0",
+    "@vueuse/head": "^0.9.7",
     "@vueuse/rxjs": "^9.1.1",
     "@vuex-orm/core": "^0.36.4",
     "codemirror": "^6.0.1",
@@ -54,7 +56,7 @@
     "crypto-js": "^4.1.1",
     "css-selector-generator": "^3.6.4",
     "dagre": "^0.8.5",
-    "dayjs": "^1.11.5",
+    "dayjs": "^1.11.6",
     "defu": "^6.1.0",
     "dexie": "^3.2.2",
     "html2canvas": "^1.4.1",
@@ -75,7 +77,6 @@
     "vue": "^3.2.37",
     "vue-i18n": "^9.2.0-beta.40",
     "vue-imask": "^6.4.2",
-    "vue-multiselect": "^3.0.0-alpha.2",
     "vue-router": "^4.1.5",
     "vue-toastification": "^2.0.0-rc.5",
     "vuedraggable": "^4.1.0",
@@ -83,7 +84,7 @@
     "webextension-polyfill": "^0.10.0"
   },
   "devDependencies": {
-    "@babel/core": "^7.18.5",
+    "@babel/core": "^7.19.6",
     "@babel/eslint-parser": "^7.18.2",
     "@babel/preset-env": "^7.18.2",
     "@intlify/vue-i18n-loader": "^4.2.0",
@@ -94,7 +95,7 @@
     "babel-loader": "^8.2.2",
     "clean-webpack-plugin": "4.0.0",
     "copy-webpack-plugin": "^11.0.0",
-    "core-js": "^3.25.0",
+    "core-js": "^3.26.0",
     "cross-env": "^7.0.3",
     "css-loader": "^6.7.1",
     "eslint": "^8.25.0",
@@ -114,9 +115,9 @@
     "postcss": "^8.4.18",
     "postcss-loader": "^7.0.0",
     "prettier": "^2.7.1",
-    "simple-git-hooks": "^2.6.1",
+    "simple-git-hooks": "^2.8.1",
     "source-map-loader": "^4.0.0",
-    "tailwindcss": "^3.1.6",
+    "tailwindcss": "^3.2.1",
     "terser-webpack-plugin": "^5.3.6",
     "vue-loader": "^17.0.0",
     "web-worker": "^1.2.0",

+ 4 - 5
src/assets/css/flow.css

@@ -6,13 +6,12 @@
 	& > div {
 		@apply rounded-lg transition;
 	}
-	&.selected > div {
+	&.selected .block-base__content {
 		@apply ring-2 ring-accent;
 	}
-	&:hover,
-	&.selected {
-		.menu {
-			@apply translate-y-11;
+	&:hover {
+		.block-menu-container {
+			display: block;
 		}
 	}
 

+ 69 - 0
src/background/BackgroundEventsListeners.js

@@ -0,0 +1,69 @@
+import browser from 'webextension-polyfill';
+import BackgroundUtils from './BackgroundUtils';
+import BackgroundWorkflowTriggers from './BackgroundWorkflowTriggers';
+
+class BackgroundEventsListeners {
+  static onActionClicked() {
+    BackgroundUtils.openDashboard();
+  }
+
+  static onCommand(name) {
+    if (name === 'open-dashboard') BackgroundUtils.openDashboard();
+  }
+
+  static onAlarms(event) {
+    BackgroundWorkflowTriggers.scheduleWorkflow(event);
+  }
+
+  static onWebNavigationCompleted({ tabId, url, frameId }) {
+    if (frameId > 0) return;
+
+    BackgroundWorkflowTriggers.visitWebTriggers(tabId, url);
+  }
+
+  static onContextMenuClicked(event, tab) {
+    BackgroundWorkflowTriggers.contextMenu(event, tab);
+  }
+
+  static onNotificationClicked(notificationId) {
+    if (notificationId.startsWith('logs')) {
+      const { 1: logId } = notificationId.split(':');
+      BackgroundUtils.openDashboard(`/logs/${logId}`);
+    }
+  }
+
+  static onRuntimeStartup() {
+    BackgroundWorkflowTriggers.reRegisterTriggers(true);
+  }
+
+  static async onRuntimeInstalled({ reason }) {
+    try {
+      if (reason === 'install') {
+        await browser.storage.local.set({
+          logs: [],
+          shortcuts: {},
+          workflows: [],
+          collections: [],
+          workflowState: {},
+          isFirstTime: true,
+          visitWebTriggers: [],
+        });
+        await browser.windows.create({
+          type: 'popup',
+          state: 'maximized',
+          url: browser.runtime.getURL('newtab.html#/welcome'),
+        });
+
+        return;
+      }
+
+      if (reason === 'update') {
+        await BackgroundWorkflowTriggers.reRegisterTriggers();
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  }
+}
+
+export default BackgroundEventsListeners;

+ 56 - 0
src/background/BackgroundUtils.js

@@ -0,0 +1,56 @@
+import browser from 'webextension-polyfill';
+import { waitTabLoaded } from '@/newtab/workflowEngine/helper';
+
+class BackgroundUtils {
+  static async openDashboard(url, updateTab = true) {
+    const tabUrl = browser.runtime.getURL(
+      `/newtab.html#${typeof url === 'string' ? url : ''}`
+    );
+
+    try {
+      const [tab] = await browser.tabs.query({
+        url: browser.runtime.getURL('/newtab.html'),
+      });
+
+      if (tab) {
+        const tabOptions = { active: true };
+        if (updateTab) tabOptions.url = tabUrl;
+
+        await browser.tabs.update(tab.id, tabOptions);
+
+        if (updateTab) {
+          await browser.windows.update(tab.windowId, {
+            focused: true,
+            state: 'maximized',
+          });
+        }
+      } else {
+        const windowOptions = {
+          url: tabUrl,
+          height: 715,
+          width: 750,
+          type: 'popup',
+          focused: updateTab,
+        };
+
+        await browser.windows.create(windowOptions);
+      }
+    } catch (error) {
+      console.error(error);
+      throw error;
+    }
+  }
+
+  static async sendMessageToDashboard(type, data) {
+    const [tab] = await browser.tabs.query({
+      url: browser.runtime.getURL('/newtab.html'),
+    });
+
+    await waitTabLoaded({ tabId: tab.id });
+    const result = await browser.tabs.sendMessage(tab.id, { type, data });
+
+    return result;
+  }
+}
+
+export default BackgroundUtils;

+ 202 - 0
src/background/BackgroundWorkflowTriggers.js

@@ -0,0 +1,202 @@
+import browser from 'webextension-polyfill';
+import dayjs from 'dayjs';
+import { findTriggerBlock, parseJSON } from '@/utils/helper';
+import {
+  registerCronJob,
+  registerSpecificDay,
+  registerWorkflowTrigger,
+} from '@/utils/workflowTrigger';
+import BackgroundWorkflowUtils from './BackgroundWorkflowUtils';
+
+class BackgroundWorkflowTriggers {
+  static async visitWebTriggers(tabId, tabUrl) {
+    const { visitWebTriggers } = await browser.storage.local.get(
+      'visitWebTriggers'
+    );
+    if (!visitWebTriggers || visitWebTriggers.length === 0) return;
+
+    const triggeredWorkflow = visitWebTriggers.find(({ url, isRegex }) => {
+      if (url.trim() === '') return false;
+
+      return tabUrl.match(isRegex ? new RegExp(url, 'g') : url);
+    });
+
+    if (triggeredWorkflow) {
+      let workflowId = triggeredWorkflow.id;
+      if (triggeredWorkflow.id.startsWith('trigger')) {
+        const { 1: triggerWorkflowId } = triggeredWorkflow.id.split(':');
+        workflowId = triggerWorkflowId;
+      }
+
+      const workflowData = await BackgroundWorkflowUtils.getWorkflow(
+        workflowId
+      );
+      if (workflowData)
+        BackgroundWorkflowUtils.executeWorkflow(workflowData, { tabId });
+    }
+  }
+
+  static async scheduleWorkflow({ name }) {
+    try {
+      let workflowId = name;
+      let triggerId = null;
+
+      if (name.startsWith('trigger')) {
+        const { 1: triggerWorkflowId, 2: triggerItemId } = name.split(':');
+        triggerId = triggerItemId;
+        workflowId = triggerWorkflowId;
+      }
+
+      const currentWorkflow = await BackgroundWorkflowUtils.getWorkflow(
+        workflowId
+      );
+      if (!currentWorkflow) return;
+
+      let data = currentWorkflow.trigger;
+      if (!data) {
+        const drawflow =
+          typeof currentWorkflow.drawflow === 'string'
+            ? parseJSON(currentWorkflow.drawflow, {})
+            : currentWorkflow.drawflow;
+        const { data: triggerBlockData } = findTriggerBlock(drawflow) || {};
+        data = triggerBlockData;
+      }
+
+      if (triggerId) {
+        data = data.triggers.find((trigger) => trigger.id === triggerId);
+        if (data) data = { ...data, ...data.data };
+      }
+
+      if (data && data.type === 'interval' && data.fixedDelay) {
+        const { workflowStates } = await browser.storage.local.get(
+          'workflowStates'
+        );
+        const workflowState = (workflowStates || []).find(
+          (item) => item.workflowId === workflowId
+        );
+
+        if (workflowState) {
+          let { workflowQueue } = await browser.storage.local.get(
+            'workflowQueue'
+          );
+          workflowQueue = workflowQueue || [];
+
+          if (!workflowQueue.includes(workflowId)) {
+            (workflowQueue = workflowQueue || []).push(workflowId);
+            await browser.storage.local.set({ workflowQueue });
+          }
+
+          return;
+        }
+      } else if (data && data.type === 'date') {
+        const [hour, minute, second] = data.time.split(':');
+        const date = dayjs(data.date)
+          .hour(hour)
+          .minute(minute)
+          .second(second || 0);
+
+        const isAfter = dayjs(Date.now() - 60 * 1000).isAfter(date);
+        if (isAfter) return;
+      }
+
+      BackgroundWorkflowUtils.executeWorkflow(currentWorkflow);
+
+      if (!data) return;
+
+      if (['specific-day', 'cron-job'].includes(data.type)) {
+        if (data.type === 'specific-day') {
+          registerSpecificDay(name, data);
+        } else {
+          registerCronJob(name, data);
+        }
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  static async contextMenu({ parentMenuItemId, menuItemId, frameId }, tab) {
+    try {
+      if (parentMenuItemId !== 'automaContextMenu') return;
+      const message = await browser.tabs.sendMessage(
+        tab.id,
+        {
+          type: 'context-element',
+        },
+        { frameId }
+      );
+
+      let workflowId = menuItemId;
+      if (menuItemId.startsWith('trigger')) {
+        const { 1: triggerWorkflowId } = menuItemId.split(':');
+        workflowId = triggerWorkflowId;
+      }
+
+      const workflowData = await BackgroundWorkflowUtils.getWorkflow(
+        workflowId
+      );
+      BackgroundWorkflowUtils.executeWorkflow(workflowData, {
+        data: {
+          variables: message,
+        },
+      });
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  static async reRegisterTriggers(isStartup = false) {
+    const { workflows, workflowHosts, teamWorkflows } =
+      await browser.storage.local.get([
+        'workflows',
+        'workflowHosts',
+        'teamWorkflows',
+      ]);
+    const convertToArr = (value) =>
+      Array.isArray(value) ? value : Object.values(value);
+
+    const workflowsArr = convertToArr(workflows);
+
+    if (workflowHosts) {
+      workflowsArr.push(...convertToArr(workflowHosts));
+    }
+    if (teamWorkflows) {
+      workflowsArr.push(
+        ...BackgroundWorkflowUtils.flattenTeamWorkflows(teamWorkflows)
+      );
+    }
+
+    for (const currWorkflow of workflowsArr) {
+      let triggerBlock = currWorkflow.trigger;
+
+      if (!triggerBlock) {
+        const flow =
+          typeof currWorkflow.drawflow === 'string'
+            ? parseJSON(currWorkflow.drawflow, {})
+            : currWorkflow.drawflow;
+
+        triggerBlock = findTriggerBlock(flow)?.data;
+      }
+
+      if (triggerBlock) {
+        if (isStartup && triggerBlock.type === 'on-startup') {
+          BackgroundWorkflowUtils.executeWorkflow(currWorkflow);
+        } else {
+          if (isStartup && triggerBlock.triggers) {
+            for (const trigger of triggerBlock.triggers) {
+              if (trigger.type === 'on-startup') {
+                await BackgroundWorkflowUtils.executeWorkflow(currWorkflow);
+              }
+            }
+          }
+
+          await registerWorkflowTrigger(currWorkflow.id, {
+            data: triggerBlock,
+          });
+        }
+      }
+    }
+  }
+}
+
+export default BackgroundWorkflowTriggers;

+ 56 - 0
src/background/BackgroundWorkflowUtils.js

@@ -0,0 +1,56 @@
+import browser from 'webextension-polyfill';
+import BackgroundUtils from './BackgroundUtils';
+
+class BackgroundWorkflowUtils {
+  static flattenTeamWorkflows(workflows) {
+    return Object.values(Object.values(workflows || {})[0] || {});
+  }
+
+  static async getWorkflow(workflowId) {
+    if (!workflowId) return null;
+
+    if (workflowId.startsWith('team')) {
+      const { teamWorkflows } = await browser.storage.local.get(
+        'teamWorkflows'
+      );
+      if (!teamWorkflows) return null;
+
+      const workflows = this.flattenTeamWorkflows(teamWorkflows);
+
+      return workflows.find((item) => item.id === workflowId);
+    }
+
+    const { workflows, workflowHosts } = await browser.storage.local.get([
+      'workflows',
+      'workflowHosts',
+    ]);
+    let findWorkflow = Array.isArray(workflows)
+      ? workflows.find(({ id }) => id === workflowId)
+      : workflows[workflowId];
+
+    if (!findWorkflow) {
+      findWorkflow = Object.values(workflowHosts || {}).find(
+        ({ hostId }) => hostId === workflowId
+      );
+
+      if (findWorkflow) findWorkflow.id = findWorkflow.hostId;
+    }
+
+    return findWorkflow;
+  }
+
+  static async executeWorkflow(workflowData, options) {
+    await BackgroundUtils.openDashboard('', false);
+    const result = await BackgroundUtils.sendMessageToDashboard(
+      'workflow:execute',
+      {
+        data: workflowData,
+        options,
+      }
+    );
+
+    return result;
+  }
+}
+
+export default BackgroundWorkflowUtils;

+ 68 - 604
src/background/index.js

@@ -1,604 +1,60 @@
 import browser from 'webextension-polyfill';
-import dayjs from '@/lib/dayjs';
 import { MessageListener } from '@/utils/message';
-import { parseJSON, findTriggerBlock, sleep } from '@/utils/helper';
-import { fetchApi } from '@/utils/api';
+import { sleep } from '@/utils/helper';
 import getFile from '@/utils/getFile';
-import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
-import convertWorkflowData from '@/utils/convertWorkflowData';
-import getBlockMessage from '@/utils/getBlockMessage';
 import automa from '@business';
-import {
-  registerCronJob,
-  registerSpecificDay,
-  registerContextMenu,
-  registerWorkflowTrigger,
-} from '../utils/workflowTrigger';
-import WorkflowState from './WorkflowState';
-import WorkflowEngine from './workflowEngine/engine';
-import blocksHandler from './workflowEngine/blocksHandler';
-import WorkflowLogger from './WorkflowLogger';
+import { registerWorkflowTrigger } from '../utils/workflowTrigger';
+import BackgroundUtils from './BackgroundUtils';
+import BackgroundWorkflowUtils from './BackgroundWorkflowUtils';
+import BackgroundEventsListeners from './BackgroundEventsListeners';
 
-const validateUrl = (str) => str?.startsWith('http');
-const flattenTeamWorkflows = (workflows) =>
-  Object.values(Object.values(workflows)[0]);
+browser.alarms.onAlarm.addListener(BackgroundEventsListeners.onAlarms);
 
-const browserStorage = {
-  async get(key) {
-    try {
-      const result = await browser.storage.local.get(key);
+browser.commands.onCommand.addListener(BackgroundEventsListeners.onCommand);
 
-      return result[key];
-    } catch (error) {
-      console.error(error);
-      return [];
-    }
-  },
-  async set(key, value) {
-    await browser.storage.local.set({ [key]: value });
-
-    if (key === 'workflowState') {
-      sessionStorage.setItem(key, JSON.stringify(value));
-    }
-  },
-};
-const localStateStorage = {
-  get(key) {
-    const data = parseJSON(localStorage.getItem(key), null);
-
-    return data;
-  },
-  set(key, value) {
-    const data = typeof value === 'object' ? JSON.stringify(value) : value;
-
-    return localStorage.setItem(key, data);
-  },
-};
-const workflow = {
-  states: new WorkflowState({ storage: localStateStorage }),
-  logger: new WorkflowLogger({ storage: browserStorage }),
-  async get(workflowId) {
-    if (!workflowId) return null;
-
-    if (workflowId.startsWith('team')) {
-      const { teamWorkflows } = await browser.storage.local.get(
-        'teamWorkflows'
-      );
-      if (!teamWorkflows) return null;
-
-      const workflows = flattenTeamWorkflows(teamWorkflows);
-
-      return workflows.find((item) => item.id === workflowId);
-    }
-
-    const { workflows, workflowHosts } = await browser.storage.local.get([
-      'workflows',
-      'workflowHosts',
-    ]);
-    let findWorkflow = Array.isArray(workflows)
-      ? workflows.find(({ id }) => id === workflowId)
-      : workflows[workflowId];
-
-    if (!findWorkflow) {
-      findWorkflow = Object.values(workflowHosts || {}).find(
-        ({ hostId }) => hostId === workflowId
-      );
-
-      if (findWorkflow) findWorkflow.id = findWorkflow.hostId;
-    }
-
-    return findWorkflow;
-  },
-  execute(workflowData, options) {
-    if (workflowData.isDisabled) return null;
-    if (workflowData.isProtected) {
-      const flow = parseJSON(workflowData.drawflow, null);
-
-      if (!flow) {
-        const pass = getWorkflowPass(workflowData.pass);
-
-        workflowData.drawflow = decryptFlow(workflowData, pass);
-      }
-    }
-
-    const convertedWorkflow = convertWorkflowData(workflowData);
-    const engine = new WorkflowEngine(convertedWorkflow, {
-      options,
-      logger: this.logger,
-      states: this.states,
-      blocksHandler: blocksHandler(),
-    });
-
-    engine.init();
-    engine.on(
-      'destroyed',
-      ({
-        id,
-        status,
-        history,
-        startedTimestamp,
-        endedTimestamp,
-        blockDetail,
-      }) => {
-        if (workflowData.id.startsWith('team') && workflowData.teamId) {
-          const payload = {
-            status,
-            workflowId: workflowData.id,
-            workflowLog: {
-              status,
-              endedTimestamp,
-              startedTimestamp,
-            },
-          };
-
-          if (status === 'error') {
-            const message = getBlockMessage(blockDetail);
-            const workflowHistory = history.map((item) => {
-              delete item.logId;
-              delete item.prevBlockData;
-              delete item.workerId;
-
-              item.description = item.description || '';
-
-              return item;
-            });
-            payload.workflowLog = {
-              status,
-              message,
-              endedTimestamp,
-              startedTimestamp,
-              history: workflowHistory,
-              blockId: blockDetail.blockId,
-            };
-          }
-
-          fetchApi(`/teams/${workflowData.teamId}/workflows/logs`, {
-            method: 'POST',
-            body: JSON.stringify(payload),
-          }).catch((error) => {
-            console.error(error);
-          });
-        }
-
-        if (status !== 'stopped') {
-          browser.permissions
-            .contains({ permissions: ['notifications'] })
-            .then((hasPermission) => {
-              if (!hasPermission || !workflowData.settings.notification) return;
-
-              const name = workflowData.name.slice(0, 32);
-
-              browser.notifications.create(`logs:${id}`, {
-                type: 'basic',
-                iconUrl: browser.runtime.getURL('icon-128.png'),
-                title: status === 'success' ? 'Success' : 'Error',
-                message: `${
-                  status === 'success' ? 'Successfully' : 'Failed'
-                } to run the "${name}" workflow`,
-              });
-            });
-        }
-      }
-    );
-
-    const lastCheckStatus = localStorage.getItem('check-status');
-    const isSameDay = dayjs().isSame(lastCheckStatus, 'day');
-    if (!isSameDay) {
-      fetchApi('/status')
-        .then((response) => response.json())
-        .then(() => {
-          localStorage.setItem('check-status', new Date());
-        });
-    }
-
-    return engine;
-  },
-};
-
-async function updateRecording(callback) {
-  const { isRecording, recording } = await browser.storage.local.get([
-    'isRecording',
-    'recording',
-  ]);
-
-  if (!isRecording || !recording) return;
-
-  callback(recording);
-
-  await browser.storage.local.set({ recording });
-}
-async function openDashboard(url) {
-  const tabOptions = {
-    active: true,
-    url: browser.runtime.getURL(
-      `/newtab.html#${typeof url === 'string' ? url : ''}`
-    ),
-  };
-
-  try {
-    const [tab] = await browser.tabs.query({
-      url: browser.runtime.getURL('/newtab.html'),
-    });
-
-    if (tab) {
-      await browser.tabs.update(tab.id, tabOptions);
-
-      if (tabOptions.url.includes('workflows/')) {
-        await browser.tabs.reload(tab.id);
-      }
-    } else {
-      browser.tabs.create(tabOptions);
-    }
-  } catch (error) {
-    console.error(error);
-  }
-}
-async function checkVisitWebTriggers(tabId, tabUrl) {
-  const visitWebTriggers = await browserStorage.get('visitWebTriggers');
-  if (!visitWebTriggers || visitWebTriggers.length === 0) return;
-
-  const workflowState = await workflow.states.get(({ state }) =>
-    state.tabIds.includes(tabId)
-  );
-  const triggeredWorkflow = visitWebTriggers?.find(({ url, isRegex, id }) => {
-    if (url.trim() === '') return false;
-
-    const matchUrl = tabUrl.match(isRegex ? new RegExp(url, 'g') : url);
-
-    return matchUrl && !id.includes(workflowState?.workflowId);
-  });
-
-  if (triggeredWorkflow) {
-    let workflowId = triggeredWorkflow.id;
-    if (triggeredWorkflow.id.startsWith('trigger')) {
-      const { 1: triggerWorkflowId } = triggeredWorkflow.id.split(':');
-      workflowId = triggerWorkflowId;
-    }
-
-    const workflowData = await workflow.get(workflowId);
-    if (workflowData && !workflow.isDisabled)
-      workflow.execute(workflowData, { tabId });
-  }
-}
-async function checkRecordingWorkflow(tabId, tabUrl) {
-  if (!validateUrl(tabUrl)) return;
-
-  const isRecording = await browserStorage.get('isRecording');
-  if (!isRecording) return;
-
-  await browser.tabs.executeScript(tabId, {
-    allFrames: true,
-    file: 'recordWorkflow.bundle.js',
-  });
-}
-browser.webNavigation.onCompleted.addListener(
-  async ({ tabId, url, frameId }) => {
-    if (frameId > 0) return;
-
-    checkRecordingWorkflow(tabId, url);
-    checkVisitWebTriggers(tabId, url);
-  }
+(browser.action || browser.browserAction).onClicked.addListener(
+  BackgroundEventsListeners.onActionClicked
 );
-browser.commands.onCommand.addListener((name) => {
-  if (name === 'open-dashboard') openDashboard();
-});
-browser.webNavigation.onCommitted.addListener(
-  ({ frameId, tabId, url, transitionType }) => {
-    const allowedType = ['link', 'typed'];
-    if (frameId !== 0 || !allowedType.includes(transitionType)) return;
 
-    updateRecording((recording) => {
-      if (tabId !== recording.activeTab.id) return;
-
-      const lastFlow = recording.flows.at(-1) ?? {};
-      const isInvalidNewtabFlow =
-        lastFlow &&
-        lastFlow.id === 'new-tab' &&
-        !validateUrl(lastFlow.data.url);
-
-      if (isInvalidNewtabFlow) {
-        lastFlow.data.url = url;
-        lastFlow.description = url;
-      } else if (validateUrl(url)) {
-        if (lastFlow?.id !== 'link' || !lastFlow.isClickLink) {
-          recording.flows.push({
-            id: 'new-tab',
-            description: url,
-            data: {
-              url,
-              updatePrevTab: recording.activeTab.id === tabId,
-            },
-          });
-        }
-
-        recording.activeTab.id = tabId;
-        recording.activeTab.url = url;
-      }
-    });
-  }
+browser.runtime.onStartup.addListener(
+  BackgroundEventsListeners.onRuntimeStartup
+);
+browser.runtime.onInstalled.addListener(
+  BackgroundEventsListeners.onRuntimeInstalled
 );
-browser.tabs.onActivated.addListener(async ({ tabId }) => {
-  const { url, id, title } = await browser.tabs.get(tabId);
-
-  if (!validateUrl(url)) return;
-
-  updateRecording((recording) => {
-    recording.activeTab = { id, url };
-    recording.flows.push({
-      id: 'switch-tab',
-      description: title,
-      data: {
-        url,
-        matchPattern: url,
-        createIfNoMatch: true,
-      },
-    });
-  });
-});
-browser.tabs.onCreated.addListener(async (tab) => {
-  const { isRecording, recording } = await browser.storage.local.get([
-    'isRecording',
-    'recording',
-  ]);
-
-  if (!isRecording || !recording) return;
-
-  const url = tab.url || tab.pendingUrl;
-  const lastFlow = recording.flows[recording.flows.length - 1];
-  const invalidPrevFlow =
-    lastFlow && lastFlow.id === 'new-tab' && !validateUrl(lastFlow.data.url);
-
-  if (!invalidPrevFlow) {
-    const validUrl = validateUrl(url) ? url : '';
-
-    recording.flows.push({
-      id: 'new-tab',
-      data: {
-        url: validUrl,
-        description: tab.title || validUrl,
-      },
-    });
-  }
-
-  recording.activeTab = {
-    url,
-    id: tab.id,
-  };
-
-  await browser.storage.local.set({ recording });
-});
-browser.alarms.onAlarm.addListener(async ({ name }) => {
-  let workflowId = name;
-  let triggerId = null;
-
-  if (name.startsWith('trigger')) {
-    const { 1: triggerWorkflowId, 2: triggerItemId } = name.split(':');
-    triggerId = triggerItemId;
-    workflowId = triggerWorkflowId;
-  }
-
-  const currentWorkflow = await workflow.get(workflowId);
-  if (!currentWorkflow) return;
-
-  let data = currentWorkflow.trigger;
-  if (!data) {
-    const drawflow =
-      typeof currentWorkflow.drawflow === 'string'
-        ? parseJSON(currentWorkflow.drawflow, {})
-        : currentWorkflow.drawflow;
-    const { data: triggerBlockData } = findTriggerBlock(drawflow) || {};
-    data = triggerBlockData;
-  }
-
-  if (triggerId) {
-    data = data.triggers.find((trigger) => trigger.id === triggerId);
-    if (data) data = { ...data, ...data.data };
-  }
-
-  if (data && data.type === 'interval' && data.fixedDelay) {
-    const workflowState = await workflow.states.get(
-      (item) => item.workflowId === workflowId
-    );
-
-    if (workflowState) {
-      let { workflowQueue } = await browser.storage.local.get('workflowQueue');
-      workflowQueue = workflowQueue || [];
-
-      if (!workflowQueue.includes(workflowId)) {
-        (workflowQueue = workflowQueue || []).push(workflowId);
-        await browser.storage.local.set({ workflowQueue });
-      }
-
-      return;
-    }
-  } else if (data && data.type === 'date') {
-    const [hour, minute, second] = data.time.split(':');
-    const date = dayjs(data.date)
-      .hour(hour)
-      .minute(minute)
-      .second(second || 0);
-
-    const isAfter = dayjs(Date.now() - 60 * 1000).isAfter(date);
-    if (isAfter) return;
-  }
-
-  workflow.execute(currentWorkflow);
-
-  if (!data) return;
 
-  if (['specific-day', 'cron-job'].includes(data.type)) {
-    if (data.type === 'specific-day') {
-      registerSpecificDay(name, data);
-    } else {
-      registerCronJob(name, data);
-    }
-  }
-});
+browser.webNavigation.onCompleted.addListener(
+  BackgroundEventsListeners.onWebNavigationCompleted
+);
 
 const contextMenu =
   BROWSER_TYPE === 'firefox' ? browser.menus : browser.contextMenus;
 if (contextMenu && contextMenu.onClicked) {
   contextMenu.onClicked.addListener(
-    async ({ parentMenuItemId, menuItemId, frameId }, tab) => {
-      try {
-        if (parentMenuItemId !== 'automaContextMenu') return;
-
-        const message = await browser.tabs.sendMessage(
-          tab.id,
-          {
-            type: 'context-element',
-          },
-          { frameId }
-        );
-        let workflowId = menuItemId;
-
-        if (menuItemId.startsWith('trigger')) {
-          const { 1: triggerWorkflowId } = menuItemId.split(':');
-          workflowId = triggerWorkflowId;
-        }
-
-        const workflowData = await workflow.get(workflowId);
-
-        workflow.execute(workflowData, {
-          data: {
-            variables: message,
-          },
-        });
-      } catch (error) {
-        console.error(error);
-      }
-    }
+    BackgroundEventsListeners.onContextMenuClicked
   );
 }
 
 if (browser.notifications && browser.notifications.onClicked) {
-  browser.notifications.onClicked.addListener((notificationId) => {
-    if (notificationId.startsWith('logs')) {
-      const { 1: logId } = notificationId.split(':');
-      openDashboard(`/logs/${logId}`);
-    }
-  });
+  browser.notifications.onClicked.addListener(
+    BackgroundEventsListeners.onNotificationClicked
+  );
 }
 
-browser.runtime.onInstalled.addListener(async ({ reason }) => {
-  try {
-    if (reason === 'install') {
-      await browser.storage.local.set({
-        logs: [],
-        shortcuts: {},
-        workflows: [],
-        collections: [],
-        workflowState: {},
-        isFirstTime: true,
-        visitWebTriggers: [],
-      });
-      await browser.tabs.create({
-        active: true,
-        url: browser.runtime.getURL('newtab.html#/welcome'),
-      });
-
-      return;
-    }
-
-    if (reason === 'update') {
-      let { workflows } = await browser.storage.local.get('workflows');
-      const alarmTypes = ['specific-day', 'date', 'interval'];
-
-      workflows = Array.isArray(workflows)
-        ? workflows
-        : Object.values(workflows);
-      workflows.forEach(({ trigger, drawflow, id }) => {
-        let workflowTrigger = trigger?.data || trigger;
-
-        if (!trigger) {
-          const flows = parseJSON(drawflow, drawflow);
-          workflowTrigger = findTriggerBlock(flows)?.data;
-        }
-
-        const triggerType = workflowTrigger?.type;
-
-        if (alarmTypes.includes(triggerType)) {
-          registerWorkflowTrigger(id, { data: workflowTrigger });
-        } else if (triggerType === 'context-menu') {
-          registerContextMenu(id, workflowTrigger);
-        }
-      });
-    }
-  } catch (error) {
-    console.error(error);
-  }
-});
-browser.runtime.onStartup.addListener(async () => {
-  try {
-    const { workflows, workflowHosts, teamWorkflows } =
-      await browser.storage.local.get([
-        'workflows',
-        'workflowHosts',
-        'teamWorkflows',
-      ]);
-    const convertToArr = (value) =>
-      Array.isArray(value) ? value : Object.values(value);
-
-    const workflowsArr = convertToArr(workflows);
-
-    if (workflowHosts) {
-      workflowsArr.push(...convertToArr(workflowHosts));
-    }
-    if (teamWorkflows) {
-      workflowsArr.push(...flattenTeamWorkflows(teamWorkflows));
-    }
-
-    for (const currWorkflow of workflowsArr) {
-      let triggerBlock = currWorkflow.trigger;
-
-      if (!triggerBlock) {
-        const flow =
-          typeof currWorkflow.drawflow === 'string'
-            ? parseJSON(currWorkflow.drawflow, {})
-            : currWorkflow.drawflow;
-
-        triggerBlock = findTriggerBlock(flow)?.data;
-      }
-
-      const executeWorkflow = async (trigger, triggerData) => {
-        if (trigger.type === 'on-startup') {
-          workflow.execute(currWorkflow);
-        } else if (trigger.type !== 'manual') {
-          await registerWorkflowTrigger(currWorkflow.id, triggerData);
-        }
-      };
+const message = new MessageListener('background');
 
-      if (triggerBlock) {
-        if (triggerBlock.triggers) {
-          for (const trigger of triggerBlock.triggers) {
-            if (trigger.type === 'on-startup') {
-              workflow.execute(currWorkflow);
-            }
-          }
+message.on('fetch', ({ type, resource }) => {
+  return fetch(resource.url, resource).then((response) => {
+    if (!response.ok) throw new Error(response.statusText);
 
-          await registerWorkflowTrigger(currWorkflow.id, {
-            data: triggerBlock,
-          });
-        } else {
-          await executeWorkflow(triggerBlock, { data: triggerBlock });
-        }
-      }
-    }
-  } catch (error) {
-    console.error(error);
-  }
+    return response[type]();
+  });
 });
-
-const message = new MessageListener('background');
-
 message.on('fetch:text', (url) => {
   return fetch(url).then((response) => response.text());
 });
-message.on('open:dashboard', async (url) => {
-  await openDashboard(url);
 
-  return Promise.resolve(true);
-});
+message.on('open:dashboard', (url) => BackgroundUtils.openDashboard(url));
 message.on('set:active-tab', (tabId) => {
   return browser.tabs.update(tabId, { active: true });
 });
@@ -636,12 +92,14 @@ message.on('debugger:type', ({ tabId, commands, delay }) => {
 
 message.on('get:sender', (_, sender) => sender);
 message.on('get:file', (path) => getFile(path));
-message.on('get:tab-screenshot', (options) =>
-  browser.tabs.captureVisibleTab(options)
+message.on('get:tab-screenshot', (options, sender) =>
+  browser.tabs.captureVisibleTab(sender.tab.windowId, options)
 );
 
 message.on('dashboard:refresh-packages', async () => {
-  const tabs = await browser.tabs.query({ url: chrome.runtime.getURL('/*') });
+  const tabs = await browser.tabs.query({
+    url: chrome.runtime.getURL('/newtab.html'),
+  });
 
   tabs.forEach((tab) => {
     browser.tabs.sendMessage(tab.id, {
@@ -657,38 +115,44 @@ message.on('workflow:execute', (workflowData, sender) => {
     workflowData.options.tabId = sender.tab.id;
   }
 
-  workflow.execute(workflowData, workflowData?.options || {});
+  BackgroundWorkflowUtils.executeWorkflow(
+    workflowData,
+    workflowData?.options || {}
+  );
 });
-message.on('workflow:stop', (id) => workflow.states.stop(id));
-message.on('workflow:added', ({ workflowId, teamId, source = 'community' }) => {
-  let path = `/workflows/${workflowId}`;
-
-  if (source === 'team') {
-    if (!teamId) return;
-    path = `/teams/${teamId}/workflows/${workflowId}`;
-  }
-
-  browser.tabs
-    .query({ url: browser.runtime.getURL('/newtab.html') })
-    .then((tabs) => {
-      if (tabs.length >= 1) {
-        const lastTab = tabs.at(-1);
-
-        tabs.forEach((tab) => {
-          browser.tabs.sendMessage(tab.id, {
-            data: { workflowId, teamId, source },
-            type: 'workflow:added',
+message.on(
+  'workflow:added',
+  ({ workflowId, teamId, workflowData, source = 'community' }) => {
+    let path = `/workflows/${workflowId}`;
+
+    if (source === 'team') {
+      if (!teamId) return;
+      path = `/teams/${teamId}/workflows/${workflowId}`;
+    }
+
+    browser.tabs
+      .query({ url: browser.runtime.getURL('/newtab.html') })
+      .then((tabs) => {
+        if (tabs.length >= 1) {
+          const lastTab = tabs.at(-1);
+
+          tabs.forEach((tab) => {
+            browser.tabs.sendMessage(tab.id, {
+              data: { workflowId, teamId, source, workflowData },
+              type: 'workflow:added',
+            });
           });
-        });
 
-        browser.tabs.update(lastTab.id, {
-          active: true,
-        });
-      } else {
-        openDashboard(`${path}?permission=true`);
-      }
-    });
-});
+          browser.tabs.update(lastTab.id, {
+            active: true,
+          });
+          browser.windows.update(lastTab.windowId, { focused: true });
+        } else {
+          BackgroundUtils.openDashboard(`${path}?permission=true`);
+        }
+      });
+  }
+);
 message.on('workflow:register', ({ triggerBlock, workflowId }) => {
   registerWorkflowTrigger(workflowId, triggerBlock);
 });

+ 0 - 109
src/background/workflowEngine/blocksHandler/handlerConditions.js

@@ -1,109 +0,0 @@
-import compareBlockValue from '@/utils/compareBlockValue';
-import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
-import testConditions from '@/utils/testConditions';
-
-function checkConditions(data, conditionOptions) {
-  return new Promise((resolve, reject) => {
-    let retryCount = 1;
-    const replacedValue = {};
-
-    const testAllConditions = async () => {
-      try {
-        for (let index = 0; index < data.conditions.length; index += 1) {
-          const result = await testConditions(
-            data.conditions[index].conditions,
-            conditionOptions
-          );
-
-          Object.assign(replacedValue, result?.replacedValue || {});
-
-          if (result.isMatch) {
-            resolve({ match: true, index, replacedValue });
-            return;
-          }
-        }
-
-        if (data.retryConditions && retryCount <= data.retryCount) {
-          retryCount += 1;
-
-          setTimeout(() => {
-            testAllConditions();
-          }, data.retryTimeout);
-        } else {
-          resolve({ match: false, replacedValue });
-        }
-      } catch (error) {
-        reject(error);
-      }
-    };
-
-    testAllConditions();
-  });
-}
-
-async function conditions({ data, id }, { prevBlockData, refData }) {
-  if (data.conditions.length === 0) {
-    throw new Error('conditions-empty');
-  }
-
-  let resultData = '';
-  let isConditionMet = false;
-  let outputId = 'fallback';
-
-  const replacedValue = {};
-  const condition = data.conditions[0];
-  const prevData = Array.isArray(prevBlockData)
-    ? prevBlockData[0]
-    : prevBlockData;
-
-  if (condition && condition.conditions) {
-    const conditionPayload = {
-      refData,
-      activeTab: this.activeTab.id,
-      sendMessage: (payload) =>
-        this._sendMessageToTab({ ...payload.data, label: 'conditions', id }),
-    };
-
-    const conditionsResult = await checkConditions(data, conditionPayload);
-
-    if (conditionsResult.replacedValue) {
-      Object.assign(replacedValue, conditionsResult.replacedValue);
-    }
-    if (conditionsResult.match) {
-      isConditionMet = true;
-      outputId = data.conditions[conditionsResult.index].id;
-    }
-  } else {
-    data.conditions.forEach(({ type, value, compareValue, id: itemId }) => {
-      if (isConditionMet) return;
-
-      const firstValue = mustacheReplacer(
-        compareValue ?? prevData,
-        refData
-      ).value;
-      const secondValue = mustacheReplacer(value, refData).value;
-
-      Object.assign(replacedValue, firstValue.list, secondValue.list);
-
-      const isMatch = compareBlockValue(
-        type,
-        firstValue.value,
-        secondValue.value
-      );
-
-      if (isMatch) {
-        outputId = itemId;
-        resultData = value;
-        isConditionMet = true;
-      }
-    });
-  }
-
-  return {
-    replacedValue,
-    data: resultData,
-    nextBlockId: this.getBlockConnections(id, outputId),
-  };
-}
-
-export default conditions;

+ 0 - 38
src/background/workflowEngine/blocksHandler/handlerCreateElement.js

@@ -1,38 +0,0 @@
-async function handleCreateElement(block, { refData }) {
-  if (!this.activeTab.id) throw new Error('no-tab');
-
-  const { data } = block;
-  const preloadScriptsPromise = await Promise.allSettled(
-    data.preloadScripts.map((item) => {
-      if (!item.src.startsWith('http'))
-        return Promise.reject(new Error('Invalid URL'));
-
-      return fetch(item.src)
-        .then((response) => response.text())
-        .then((result) => ({ type: item.type, script: result }));
-    })
-  );
-  const preloadScripts = preloadScriptsPromise.reduce((acc, item) => {
-    if (item.status === 'rejected') return acc;
-
-    acc.push(item.value);
-
-    return acc;
-  }, []);
-
-  data.preloadScripts = preloadScripts;
-
-  const payload = { ...block, data, refData: { variables: {} } };
-  if (data.javascript.includes('automaRefData')) {
-    payload.refData = { ...refData, secrets: {} };
-  }
-
-  await this._sendMessageToTab(payload, {}, data.runBeforeLoad ?? false);
-
-  return {
-    data: '',
-    nextBlockId: this.getBlockConnections(block.id),
-  };
-}
-
-export default handleCreateElement;

+ 0 - 58
src/background/workflowEngine/blocksHandler/handlerJavascriptCode.js

@@ -1,58 +0,0 @@
-export async function javascriptCode({ outputs, data, ...block }, { refData }) {
-  const nextBlockId = this.getBlockConnections(block.id);
-
-  if (data.everyNewTab) {
-    const isScriptExist = this.preloadScripts.find(({ id }) => id === block.id);
-
-    if (!isScriptExist) {
-      this.preloadScripts.push({ ...block, data });
-    }
-  }
-  if (!this.activeTab.id) {
-    if (!data.everyNewTab) {
-      throw new Error('no-tab');
-    } else {
-      return { data: '', nextBlockId };
-    }
-  }
-
-  const payload = { ...block, data, refData: { variables: {} } };
-  if (data.code.includes('automaRefData')) {
-    payload.refData = { ...refData, secrets: {} };
-  }
-
-  if (!data.code.includes('automaNextBlock'))
-    payload.data.code += `\nautomaNextBlock()`;
-
-  const result = await this._sendMessageToTab(
-    payload,
-    {},
-    data.runBeforeLoad ?? false
-  );
-  if (result) {
-    if (result.columns.data?.$error) {
-      throw new Error(result.columns.data.message);
-    }
-
-    if (result.variables) {
-      Object.keys(result.variables).forEach((varName) => {
-        this.setVariable(varName, result.variables[varName]);
-      });
-    }
-
-    if (result.columns.insert && result.columns.data) {
-      const params = Array.isArray(result.columns.data)
-        ? result.columns.data
-        : [result.columns.data];
-
-      this.addDataToColumn(params);
-    }
-  }
-
-  return {
-    nextBlockId,
-    data: result?.columns.data || {},
-  };
-}
-
-export default javascriptCode;

+ 90 - 36
src/components/block/BlockBase.vue

@@ -1,60 +1,114 @@
 <template>
   <div class="block-base relative w-48" @dblclick.stop="$emit('edit')">
-    <slot name="prepend" />
-    <ui-card :class="contentClass" class="z-10 relative block-base__content">
-      <slot></slot>
-    </ui-card>
-    <slot name="append" />
     <div
-      v-if="!minimap"
-      class="absolute bottom-1 transition-transform duration-300 pt-4 ml-1 menu"
+      class="top-0 w-full absolute block-menu-container hidden"
+      style="transform: translateY(-100%)"
     >
-      <div
-        class="bg-accent dark:bg-gray-100 dark:text-black text-white rounded-lg flex items-center"
-      >
-        <button
-          v-if="!hideEdit"
-          class="px-3 focus:ring-0 py-2"
-          title="Edit block"
-          @click="$emit('edit')"
-        >
-          <v-remixicon size="20" name="riPencilLine" />
-        </button>
-        <hr
-          v-if="!hideDelete && !hideEdit"
-          class="border-r border-gray-600 h-5"
-        />
+      <div class="inline-flex items-center dark:text-gray-300 block-menu">
         <button
-          v-if="!hideDelete"
-          class="px-3 focus:ring-0 py-2"
+          v-if="!blockData.details?.disableDelete"
           title="Delete block"
           @click.stop="$emit('delete')"
         >
           <v-remixicon size="20" name="riDeleteBin7Line" />
         </button>
+        <button
+          :title="$t('workflow.blocks.base.settings.title')"
+          @click.stop="
+            $emit('settings', { details: blockData.details, data, blockId })
+          "
+        >
+          <v-remixicon size="20" name="riSettings3Line" />
+        </button>
+        <button
+          v-if="!excludeGroupBlocks.includes(blockData.details?.id)"
+          :title="$t('workflow.blocks.base.moveToGroup')"
+          draggable="true"
+          class="cursor-move"
+          @dragstart="handleStartDrag"
+          @mousedown.stop
+        >
+          <v-remixicon name="riDragDropLine" size="20" />
+        </button>
+        <button
+          v-if="blockData.details?.id !== 'trigger'"
+          title="Enable/Disable block"
+          @click.stop="$emit('update', { disableBlock: !data.disableBlock })"
+        >
+          <v-remixicon
+            size="20"
+            :name="data.disableBlock ? 'riToggleLine' : 'riToggleFill'"
+          />
+        </button>
+        <button title="Run workflow from here" @click.stop="runWorkflow">
+          <v-remixicon size="20" name="riPlayLine" />
+        </button>
+        <button
+          v-if="!blockData.details?.disableEdit"
+          title="Edit block"
+          @click="$emit('edit')"
+        >
+          <v-remixicon size="20" name="riPencilLine" />
+        </button>
         <slot name="action" />
       </div>
     </div>
+    <slot name="prepend" />
+    <ui-card :class="contentClass" class="z-10 relative block-base__content">
+      <slot></slot>
+    </ui-card>
+    <slot name="append" />
   </div>
 </template>
 <script setup>
-defineProps({
-  hideDelete: {
-    type: Boolean,
-    default: false,
+import { inject } from 'vue';
+import { excludeGroupBlocks } from '@/utils/shared';
+
+const props = defineProps({
+  contentClass: {
+    type: String,
+    default: '',
   },
-  minimap: {
-    type: Boolean,
-    default: false,
+  blockData: {
+    type: Object,
+    default: () => ({}),
   },
-  hideEdit: {
-    type: Boolean,
-    default: false,
+  data: {
+    type: Object,
+    default: () => ({}),
   },
-  contentClass: {
+  blockId: {
     type: String,
     default: '',
   },
 });
-defineEmits(['delete', 'edit']);
+defineEmits(['delete', 'edit', 'update', 'settings']);
+
+const workflowUtils = inject('workflow-utils', null);
+
+function handleStartDrag(event) {
+  const payload = {
+    data: props.data,
+    fromBlockBasic: true,
+    blockId: props.blockId,
+    id: props.blockData.details.id,
+  };
+
+  event.dataTransfer.setData('block', JSON.stringify(payload));
+}
+function runWorkflow() {
+  if (!workflowUtils) return;
+
+  workflowUtils.executeFromBlock(props.blockId);
+}
 </script>
+<style>
+.block-menu {
+  @apply mb-1 bg-box-transparent-2 rounded-md;
+  button {
+    padding-left: 6px;
+    padding-right: 6px;
+    @apply focus:ring-0 py-1 hover:text-primary;
+  }
+}
+</style>

+ 10 - 27
src/components/block/BlockBasic.vue

@@ -1,12 +1,15 @@
 <template>
   <block-base
     :id="componentId"
-    :hide-edit="block.details.disableEdit"
-    :hide-delete="block.details.disableDelete"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
     :data-position="JSON.stringify(position)"
     class="block-basic group"
     @edit="$emit('edit')"
     @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
   >
     <Handle
       v-if="label !== 'trigger'"
@@ -38,7 +41,7 @@
           {{ data.description }}
         </p>
         <span
-          v-if="data.loopId"
+          v-if="loopBlocks.includes(block.details.id) && data.loopId"
           class="bg-box-transparent rounded-br-lg text-gray-600 dark:text-gray-200 text-overflow rounded-sm py-px px-1 text-xs absolute bottom-0 right-0"
           title="Loop Id (click to copy)"
           style="max-width: 40%; cursor: pointer"
@@ -49,18 +52,6 @@
       </div>
     </div>
     <slot :block="block"></slot>
-    <template #prepend>
-      <div
-        v-if="block.details.id !== 'trigger'"
-        :title="t('workflow.blocks.base.moveToGroup')"
-        draggable="true"
-        class="bg-white dark:bg-gray-700 invisible group-hover:visible z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
-        @dragstart="handleStartDrag"
-        @mousedown.stop
-      >
-        <v-remixicon name="riDragDropLine" size="20" />
-      </div>
-    </template>
     <div
       v-if="data.onError?.enable && data.onError?.toDo === 'fallback'"
       class="fallback flex items-center justify-end"
@@ -86,7 +77,7 @@
   </block-base>
 </template>
 <script setup>
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { useI18n } from 'vue-i18n';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useComponentId } from '@/composable/componentId';
@@ -118,22 +109,14 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-defineEmits(['delete', 'edit', 'update']);
+defineEmits(['delete', 'edit', 'update', 'settings']);
+
+const loopBlocks = ['loop-data', 'loop-elements'];
 
 const { t, te } = useI18n();
 const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-base');
 
-function handleStartDrag(event) {
-  const payload = {
-    data: props.data,
-    id: block.details.id,
-    blockId: props.id,
-    fromBlockBasic: true,
-  };
-
-  event.dataTransfer.setData('block', JSON.stringify(payload));
-}
 function copyLoopId() {
   navigator.clipboard.writeText(props.data.loopId);
 }

+ 7 - 27
src/components/block/BlockBasicWithFallback.vue

@@ -1,11 +1,14 @@
 <template>
   <block-base
     :id="componentId"
-    :hide-edit="block.details.disableEdit"
-    :hide-delete="block.details.disableDelete"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
     class="block-basic group"
     @edit="$emit('edit')"
     @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
   >
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center">
@@ -46,22 +49,10 @@
       :position="Position.Right"
       style="top: auto; bottom: 10px"
     />
-    <template #prepend>
-      <div
-        v-if="block.details.id !== 'trigger'"
-        :title="t('workflow.blocks.base.moveToGroup')"
-        draggable="true"
-        class="bg-white dark:bg-gray-700 invisible group-hover:visible z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
-        @dragstart="handleStartDrag"
-        @mousedown.stop
-      >
-        <v-remixicon name="riDragDropLine" size="20" />
-      </div>
-    </template>
   </block-base>
 </template>
 <script setup>
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { useI18n } from 'vue-i18n';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useComponentId } from '@/composable/componentId';
@@ -81,20 +72,9 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-defineEmits(['delete', 'edit', 'update']);
+defineEmits(['delete', 'edit', 'update', 'settings']);
 
 const { t } = useI18n();
 const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-base');
-
-function handleStartDrag(event) {
-  const payload = {
-    data: block.data,
-    id: block.details.id,
-    blockId: block.id,
-    fromBlockBasic: true,
-  };
-
-  event.dataTransfer.setData('block', JSON.stringify(payload));
-}
 </script>

+ 7 - 2
src/components/block/BlockConditions.vue

@@ -1,9 +1,14 @@
 <template>
   <block-base
     :id="componentId"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
     class="w-64"
     @edit="$emit('edit')"
     @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
   >
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center">
@@ -75,7 +80,7 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import BlockBase from './BlockBase.vue';
@@ -94,7 +99,7 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-defineEmits(['delete', 'edit']);
+defineEmits(['delete', 'settings', 'edit', 'update']);
 
 const { t } = useI18n();
 const componentId = useComponentId('block-conditions');

+ 15 - 5
src/components/block/BlockDelay.vue

@@ -1,9 +1,18 @@
 <template>
-  <ui-card :id="componentId" class="p-4 w-48 block-basic">
+  <block-base
+    :id="componentId"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
+    class="w-48"
+    @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
+  >
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
       <div
-        :class="block.category.color"
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
         class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riTimerLine" size="20" class="inline-block mr-1" />
@@ -37,13 +46,14 @@
       <v-remixicon name="riDragDropLine" size="20" />
     </div>
     <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
-  </ui-card>
+  </block-base>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
+import BlockBase from './BlockBase.vue';
 
 const props = defineProps({
   id: {
@@ -59,7 +69,7 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-defineEmits(['update', 'delete']);
+defineEmits(['update', 'delete', 'settings']);
 
 const { t } = useI18n();
 const block = useEditorBlock(props.label);

+ 7 - 3
src/components/block/BlockElementExists.vue

@@ -1,10 +1,14 @@
 <template>
   <block-base
     :id="componentId"
-    class="element-exists"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
     style="width: 195px"
     @edit="$emit('edit')"
     @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
   >
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div
@@ -43,7 +47,7 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import BlockBase from './BlockBase.vue';
@@ -62,7 +66,7 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-defineEmits(['delete', 'edit']);
+defineEmits(['delete', 'edit', 'update', 'settings']);
 
 const { t } = useI18n();
 const block = useEditorBlock(props.label);

+ 16 - 24
src/components/block/BlockGroup.vue

@@ -1,5 +1,16 @@
 <template>
-  <ui-card :id="componentId" class="w-64" padding="p-0">
+  <block-base
+    :id="componentId"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
+    class="w-64"
+    content-class="p-0"
+    @edit="$emit('edit')"
+    @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
+  >
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="p-4">
       <div class="flex items-center mb-2">
@@ -16,26 +27,6 @@
           />
           <span>{{ t('workflow.blocks.blocks-group.name') }}</span>
         </div>
-        <div class="flex-grow"></div>
-        <ui-popover>
-          <template #trigger>
-            <v-remixicon name="riMoreLine" class="cursor-pointer" />
-          </template>
-          <ui-list class="w-36 space-y-1">
-            <ui-list-item
-              class="cursor-pointer"
-              @click.stop="emit('update', { disableBlock: !data.disableBlock })"
-            >
-              {{ t(`common.${data.disableBlock ? 'enable' : 'disable'}`) }}
-            </ui-list-item>
-            <ui-list-item
-              class="text-red-400 dark:text-red-500 cursor-pointer"
-              @click.stop="emit('delete', id)"
-            >
-              {{ t('common.delete') }}
-            </ui-list-item>
-          </ui-list>
-        </ui-popover>
       </div>
       <input
         :value="data.name"
@@ -107,18 +98,19 @@
       </template>
     </draggable>
     <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
-  </ui-card>
+  </block-base>
 </template>
 <script setup>
 import { inject, computed, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
 import { useToast } from 'vue-toastification';
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import draggable from 'vuedraggable';
 import { tasks, excludeGroupBlocks } from '@/utils/shared';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
+import BlockBase from './BlockBase.vue';
 
 const props = defineProps({
   id: {
@@ -142,7 +134,7 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-const emit = defineEmits(['update', 'delete', 'edit']);
+const emit = defineEmits(['update', 'delete', 'edit', 'settings']);
 
 const { t, te } = useI18n();
 const toast = useToast();

+ 15 - 5
src/components/block/BlockLoopBreakpoint.vue

@@ -1,9 +1,18 @@
 <template>
-  <ui-card :id="componentId" class="w-48">
+  <block-base
+    :id="componentId"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
+    class="w-48"
+    @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
+  >
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
       <div
-        :class="block.category.color"
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
         class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black text-overflow"
       >
         <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
@@ -32,13 +41,14 @@
       Stop loop
     </ui-checkbox>
     <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
-  </ui-card>
+  </block-base>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
+import BlockBase from './BlockBase.vue';
 
 const props = defineProps({
   id: {
@@ -54,7 +64,7 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-const emit = defineEmits(['delete', 'update']);
+const emit = defineEmits(['delete', 'update', 'settings']);
 
 const { t } = useI18n();
 const block = useEditorBlock(props.label);

+ 19 - 7
src/components/block/BlockPackage.vue

@@ -1,5 +1,14 @@
 <template>
-  <ui-card :id="componentId" class="p-4 w-64 block-package">
+  <block-base
+    :id="componentId"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
+    class="w-64 block-package"
+    @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
+  >
     <div class="flex items-center">
       <img
         v-if="data.icon.startsWith('http')"
@@ -82,15 +91,16 @@
         <v-remixicon size="18" name="riExternalLinkLine" />
       </a>
     </div>
-  </ui-card>
+  </block-base>
 </template>
 <script setup>
 import { onMounted, shallowReactive } from 'vue';
 import cloneDeep from 'lodash.clonedeep';
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { usePackageStore } from '@/stores/package';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
+import BlockBase from './BlockBase.vue';
 
 const props = defineProps({
   id: {
@@ -110,7 +120,7 @@ const props = defineProps({
     default: null,
   },
 });
-const emit = defineEmits(['update', 'delete']);
+const emit = defineEmits(['update', 'delete', 'settings']);
 
 const packageStore = usePackageStore();
 const block = useEditorBlock(props.label);
@@ -121,9 +131,11 @@ const state = shallowReactive({
 });
 
 function installPackage() {
-  packageStore.insert({ ...props.data }, false).then(() => {
-    state.isInstalled = true;
-  });
+  packageStore
+    .insert({ ...props.data, isExternal: Boolean(props.data.author) }, false)
+    .then(() => {
+      state.isInstalled = true;
+    });
 }
 function removeConnections(type, old, newEdges) {
   const removedEdges = [];

+ 15 - 11
src/components/block/BlockRepeatTask.vue

@@ -1,20 +1,23 @@
 <template>
-  <ui-card :id="componentId" class="p-4 repeat-task w-64">
+  <block-base
+    :id="componentId"
+    :data="data"
+    :block-id="id"
+    :block-data="block"
+    class="repeat-task w-64"
+    @delete="$emit('delete', id)"
+    @update="$emit('update', $event)"
+    @settings="$emit('settings', $event)"
+  >
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
       <div
-        :class="block.category.color"
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
         class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riRepeat2Line" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.repeat-task.name') }}</span>
       </div>
-      <div class="flex-grow"></div>
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="cursor-pointer"
-        @click="$emit('delete', id)"
-      />
     </div>
     <div class="flex bg-input rounded-lg items-center relative">
       <input
@@ -39,13 +42,14 @@
       :position="Position.Right"
       style="top: auto; bottom: 12px"
     />
-  </ui-card>
+  </block-base>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import { Handle, Position } from '@braks/vue-flow';
+import { Handle, Position } from '@vue-flow/core';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
+import BlockBase from './BlockBase.vue';
 
 const { t } = useI18n();
 const props = defineProps({
@@ -62,7 +66,7 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-const emit = defineEmits(['delete', 'update']);
+const emit = defineEmits(['delete', 'update', 'settings']);
 
 const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');

+ 3 - 1
src/components/content/selector/SelectorElementsDetail.vue

@@ -1,5 +1,6 @@
 <template>
   <ui-tabs
+    v-if="!hideBlocks || selectElements.length > 0"
     :model-value="activeTab"
     class="mt-2"
     fill
@@ -7,7 +8,7 @@
   >
     <ui-tab value="attributes"> Attributes </ui-tab>
     <ui-tab v-if="selectElements.length > 0" value="options"> Options </ui-tab>
-    <ui-tab value="blocks"> Blocks </ui-tab>
+    <ui-tab v-if="!hideBlocks" value="blocks"> Blocks </ui-tab>
   </ui-tabs>
   <ui-tab-panels
     :model-value="activeTab"
@@ -118,6 +119,7 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  hideBlocks: Boolean,
 });
 defineEmits(['update:activeTab', 'execute', 'highlight', 'update']);
 

+ 2 - 2
src/components/content/shared/SharedElementHighlighter.vue

@@ -4,11 +4,11 @@
     v-bind="{
       x: getNumber(item?.x),
       y: getNumber(item?.y),
+      fill: getFillColor(item),
+      stroke: getStrokeColor(item),
       width: getNumber(item?.width),
       height: getNumber(item?.height),
       'stroke-dasharray': item?.outline ? '5,5' : null,
-      fill: getFillColor(item),
-      stroke: getStrokeColor(item),
     }"
     :key="index"
     stroke-width="2"

+ 24 - 0
src/components/newtab/app/AppSidebar.vue

@@ -48,6 +48,14 @@
         </a>
       </router-link>
     </div>
+    <hr class="w-8/12 my-4" />
+    <button
+      v-tooltip:right.group="$t('home.elementSelector.name')"
+      class="focus:ring-0"
+      @click="injectElementSelector"
+    >
+      <v-remixicon name="riFocus3Line" />
+    </button>
     <div class="flex-grow"></div>
     <ui-popover
       v-if="userStore.user"
@@ -108,16 +116,19 @@
 import { ref, computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
+import { useToast } from 'vue-toastification';
 import browser from 'webextension-polyfill';
 import { useUserStore } from '@/stores/user';
 import { useWorkflowStore } from '@/stores/workflow';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { communities } from '@/utils/shared';
+import { initElementSelector } from '@/newtab/utils/elementSelector';
 
 useGroupTooltip();
 
 const { t } = useI18n();
+const toast = useToast();
 const router = useRouter();
 const userStore = useUserStore();
 const workflowStore = useWorkflowStore();
@@ -189,6 +200,19 @@ function hoverHandler({ target }) {
   showHoverIndicator.value = true;
   hoverIndicator.value.style.transform = `translate(-50%, ${target.offsetTop}px)`;
 }
+async function injectElementSelector() {
+  try {
+    const [tab] = await browser.tabs.query({ active: true, url: '*://*/*' });
+    if (!tab) {
+      toast.error(t('home.elementSelector.noAccess'));
+      return;
+    }
+
+    await initElementSelector(tab);
+  } catch (error) {
+    console.error(error);
+  }
+}
 </script>
 <style scoped>
 .tab.is-active:after {

+ 5 - 5
src/components/newtab/logs/LogsFilters.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="flex items-center mb-6 space-x-4">
+  <div class="flex items-center mb-6 md:space-x-4 flex-wrap">
     <ui-input
       id="search-input"
       :model-value="filters.query"
@@ -7,10 +7,10 @@
         shortcut['action:search'].readable
       })`"
       prepend-icon="riSearch2Line"
-      class="flex-1"
+      class="w-6/12 md:w-auto md:flex-1"
       @change="updateFilters('query', $event)"
     />
-    <div class="flex items-center workflow-sort">
+    <div class="flex items-center workflow-sort w-5/12 ml-4 md:ml-0 md:w-auto">
       <ui-button
         icon
         class="rounded-r-none border-gray-300 border-r"
@@ -30,7 +30,7 @@
         </option>
       </ui-select>
     </div>
-    <ui-popover>
+    <ui-popover class="mt-4 md:mt-0">
       <template #trigger>
         <ui-button>
           <v-remixicon name="riFilter2Line" class="mr-2 -ml-1" />
@@ -68,7 +68,7 @@
         </ui-select>
       </div>
     </ui-popover>
-    <ui-button @click="$emit('clear')">
+    <ui-button class="ml-4 md:ml-0 mt-4 md:mt-0" @click="$emit('clear')">
       <v-remixicon name="riDeleteBin7Line" class="mr-2 -ml-1" />
       <span>
         {{ t('log.clearLogs.title') }}

+ 16 - 8
src/components/newtab/logs/LogsHistory.vue

@@ -8,8 +8,8 @@
     <v-remixicon name="riArrowLeftLine" class="mr-2" />
     {{ t('log.goBack', { name: parentLog.name }) }}
   </router-link>
-  <div class="flex items-start">
-    <div class="flex-1">
+  <div class="flex items-start flex-col-reverse lg:flex-row">
+    <div class="lg:flex-1 w-full lg:w-auto">
       <div class="rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark">
         <div
           class="border-b px-4 pt-4 flex items-center text-gray-200 pb-4 mb-4"
@@ -19,7 +19,7 @@
               {{ errorBlock.message }}
               <a
                 v-if="errorBlock.messageId"
-                :href="`https://docs.automa.site/guide/workflow-errors.html#${errorBlock.messageId}`"
+                :href="`https://docs.automa.site/reference/workflow-common-errors.html#${errorBlock.messageId}`"
                 target="_blank"
                 title="About the error"
                 @click.stop
@@ -47,7 +47,9 @@
           <ui-popover trigger-width class="mr-4">
             <template #trigger>
               <ui-button>
-                <span>Export logs</span>
+                <span>
+                  Export <span class="hidden lg:inline-block">logs</span>
+                </span>
                 <v-remixicon name="riArrowDropDownLine" class="ml-2 -mr-1" />
               </ui-button>
             </template>
@@ -75,6 +77,12 @@
           class="scroll p-4 overflow-auto"
         >
           <slot name="prepend" />
+          <p
+            v-if="currentLog.history.length === 0"
+            class="text-gray-300 text-center"
+          >
+            The workflow log is not saved
+          </p>
           <div class="text-sm font-mono space-y-1 w-full overflow-auto">
             <div
               v-for="(item, index) in history"
@@ -127,7 +135,7 @@
                 {{ item.message }}
                 <a
                   v-if="item.messageId"
-                  :href="`https://docs.automa.site/guide/workflow-errors.html#${item.messageId}`"
+                  :href="`https://docs.automa.site/reference/workflow-common-errors.html#${item.messageId}`"
                   target="_blank"
                   title="About the error"
                   @click.stop
@@ -173,9 +181,9 @@
       </div>
       <div
         v-if="currentLog.history.length >= 25"
-        class="flex items-center justify-between mt-4"
+        class="lg:flex lg:items-center lg:justify-between mt-4"
       >
-        <div>
+        <div class="mb-4 lg:mb-0">
           {{ t('components.pagination.text1') }}
           <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
             <option
@@ -201,7 +209,7 @@
     </div>
     <div
       v-if="state.itemId && activeLog"
-      class="w-4/12 ml-8 rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark"
+      class="w-full lg:w-4/12 lg:ml-8 mb-4 lg:mb-0 rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark"
     >
       <div class="p-4 relative">
         <v-remixicon

+ 40 - 6
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -23,12 +23,32 @@
           </option>
         </optgroup>
       </ui-select>
-      <template v-for="(_, name) in item.data" :key="item.id + name">
-        <v-remixicon
-          v-if="name === 'code'"
-          :title="t('workflow.conditionBuilder.topAwait')"
-          name="riInformationLine"
-        />
+      <template
+        v-for="name in getConditionDataList(item)"
+        :key="item.id + name"
+      >
+        <template v-if="name === 'code'">
+          <ui-select
+            v-model="inputsData[index].data.context"
+            :placeholder="t('workflow.blocks.javascript-code.context.name')"
+            class="mr-2"
+          >
+            <option
+              v-for="context in ['website', 'background']"
+              :key="context"
+              :disabled="isFirefox && context === 'background'"
+              :value="context"
+            >
+              {{
+                t(`workflow.blocks.javascript-code.context.items.${context}`)
+              }}
+            </option>
+          </ui-select>
+          <v-remixicon
+            :title="t('workflow.conditionBuilder.topAwait')"
+            name="riInformationLine"
+          />
+        </template>
         <edit-autocomplete
           :disabled="name === 'code'"
           :class="[name === 'code' ? 'w-full' : 'flex-1']"
@@ -50,6 +70,10 @@
             class="w-full"
           />
         </edit-autocomplete>
+        <SharedElSelectorActions
+          v-if="name === 'selector'"
+          v-model:selector="inputsData[index].data[name]"
+        />
       </template>
     </div>
     <ui-select
@@ -81,6 +105,7 @@ import {
   completeFromGlobalScope,
 } from '@/utils/codeEditorAutocomplete';
 import { conditionBuilder } from '@/utils/shared';
+import SharedElSelectorActions from '@/components/newtab/shared/SharedElSelectorActions.vue';
 import EditAutocomplete from '../../workflow/edit/EditAutocomplete.vue';
 
 const SharedCodemirror = defineAsyncComponent(() =>
@@ -99,6 +124,7 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
+const isFirefox = BROWSER_TYPE === 'firefox';
 const autocompleteList = [automaFuncsSnippets.automaRefData];
 const codemirrorExts = [
   autocompletion({
@@ -116,9 +142,17 @@ const conditionOperators = conditionBuilder.compareTypes.reduce((acc, type) => {
   return acc;
 }, {});
 
+const excludeData = ['context'];
+
 const { t } = useI18n();
 const inputsData = ref(cloneDeep(props.data));
 
+function getConditionDataList(inputData) {
+  const keys = Object.keys(inputData.data);
+  const filteredKeys = keys.filter((item) => !excludeData.includes(item));
+
+  return filteredKeys;
+}
 function getDefaultValues(items) {
   const defaultValues = {
     value: {

+ 63 - 0
src/components/newtab/shared/SharedElSelectorActions.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="inline-flex items-center">
+    <ui-button
+      v-tooltip.group="$t('workflow.blocks.base.element.select')"
+      icon
+      class="mr-2"
+      @click="selectElement"
+    >
+      <v-remixicon name="riFocus3Line" />
+    </ui-button>
+    <ui-button
+      v-tooltip.group="$t('workflow.blocks.base.element.verify')"
+      :disabled="!selector"
+      icon
+      @click="verifySelector"
+    >
+      <v-remixicon name="riCheckDoubleLine" />
+    </ui-button>
+  </div>
+</template>
+<script setup>
+import { useToast } from 'vue-toastification';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import elementSelector from '@/newtab/utils/elementSelector';
+
+const props = defineProps({
+  findBy: {
+    type: String,
+    default: null,
+  },
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
+  selector: {
+    type: String,
+    default: '',
+  },
+});
+const emit = defineEmits(['update:selector']);
+
+useGroupTooltip();
+const toast = useToast();
+
+function selectElement() {
+  elementSelector.selectElement().then((selector) => {
+    emit('update:selector', selector);
+  });
+}
+function verifySelector() {
+  elementSelector
+    .verifySelector({
+      selector: props.selector,
+      multiple: props.multiple,
+      findBy: props.findBy,
+    })
+    .then((result) => {
+      if (!result.notFound) return;
+
+      toast.error('Element not found');
+    });
+}
+</script>

+ 10 - 6
src/components/newtab/shared/SharedLogsTable.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="logs-table">
+  <div class="logs-table overflow-x-auto scroll">
     <transition-expand>
       <div v-if="state.selected.length > 0" class="border-x border-t px-4 py-2">
         <ui-button @click="stopSelectedWorkflow"> Stop selected </ui-button>
@@ -26,8 +26,8 @@
               </router-link>
             </td>
             <td
-              class="log-time w-2/12 dark:text-gray-200"
               :title="t('log.duration')"
+              class="log-time w-2/12 dark:text-gray-200"
             >
               <v-remixicon name="riTimerLine"></v-remixicon>
               <span>{{
@@ -73,7 +73,10 @@
               {{ log.name }}
             </router-link>
           </td>
-          <td class="log-time w-3/12 dark:text-gray-200">
+          <td
+            class="log-time w-3/12 dark:text-gray-200"
+            style="min-width: 200px"
+          >
             <v-remixicon
               :title="t('log.startedDate')"
               name="riCalendarLine"
@@ -84,8 +87,9 @@
             </span>
           </td>
           <td
-            class="log-time w-2/12 dark:text-gray-200"
             :title="t('log.duration')"
+            class="log-time w-2/12 dark:text-gray-200"
+            style="min-width: 85px"
           >
             <v-remixicon name="riTimerLine"></v-remixicon>
             <span>{{ countDuration(log.startedAt, log.endedAt) }}</span>
@@ -109,8 +113,8 @@
 <script setup>
 import { reactive } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { sendMessage } from '@/utils/message';
 import { countDuration } from '@/utils/helper';
+import { workflowState } from '@/newtab/workflowEngine';
 import dayjs from '@/lib/dayjs';
 
 defineProps({
@@ -140,7 +144,7 @@ function getTranslation(key, defText = '') {
   return te(key) ? t(key) : defText;
 }
 function stopWorkflow(stateId) {
-  sendMessage('workflow:stop', stateId, 'background');
+  workflowState.stop(stateId);
 }
 function toggleSelectedLog(selected, id) {
   if (selected) {

+ 2 - 6
src/components/newtab/shared/SharedWorkflowState.vue

@@ -58,8 +58,8 @@
 <script setup>
 import browser from 'webextension-polyfill';
 import { useI18n } from 'vue-i18n';
-import { sendMessage } from '@/utils/message';
 import { getBlocks } from '@/utils/getSharedData';
+import { workflowState } from '@/newtab/workflowEngine';
 import dayjs from '@/lib/dayjs';
 
 const props = defineProps({
@@ -81,10 +81,6 @@ function openTab() {
   browser.tabs.update(props.data.state.tabId, { active: true });
 }
 function stopWorkflow() {
-  sendMessage(
-    props.data.isCollection ? 'collection:stop' : 'workflow:stop',
-    props.data.id,
-    'background'
-  );
+  workflowState.stop(props.data.id);
 }
 </script>

+ 6 - 1
src/components/newtab/storage/StorageCredentials.vue

@@ -6,7 +6,12 @@
       prepend-icon="riSearch2Line"
     />
     <div class="flex-grow"></div>
-    <ui-button variant="accent" @click="addState.show = true">
+    <ui-button
+      variant="accent"
+      style="min-width: 120px"
+      class="ml-4"
+      @click="addState.show = true"
+    >
       {{ t('credential.add') }}
     </ui-button>
   </div>

+ 45 - 31
src/components/newtab/storage/StorageTables.vue

@@ -6,40 +6,47 @@
       prepend-icon="riSearch2Line"
     />
     <div class="flex-grow"></div>
-    <ui-button variant="accent" @click="state.showAddTable = true">
+    <ui-button
+      variant="accent"
+      class="ml-4"
+      style="min-width: 120px"
+      @click="state.showAddTable = true"
+    >
       {{ t('storage.table.add') }}
     </ui-button>
   </div>
-  <ui-table
-    item-key="id"
-    :headers="tableHeaders"
-    :items="items"
-    :search="state.query"
-    class="w-full mt-4"
-  >
-    <template #item-name="{ item }">
-      <router-link
-        :to="`/storage/tables/${item.id}`"
-        class="w-full block"
-        style="min-height: 29px"
-      >
-        {{ item.name }}
-      </router-link>
-    </template>
-    <template #item-createdAt="{ item }">
-      {{ formatDate(item.createdAt) }}
-    </template>
-    <template #item-modifiedAt="{ item }">
-      {{ formatDate(item.modifiedAt) }}
-    </template>
-    <template #item-actions="{ item }">
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="cursor-pointer"
-        @click="deleteTable(item)"
-      />
-    </template>
-  </ui-table>
+  <div class="overflow-x-auto w-full scroll">
+    <ui-table
+      item-key="id"
+      :headers="tableHeaders"
+      :items="items"
+      :search="state.query"
+      class="w-full mt-4"
+    >
+      <template #item-name="{ item }">
+        <router-link
+          :to="`/storage/tables/${item.id}`"
+          class="w-full block"
+          style="min-height: 29px"
+        >
+          {{ item.name }}
+        </router-link>
+      </template>
+      <template #item-createdAt="{ item }">
+        {{ formatDate(item.createdAt) }}
+      </template>
+      <template #item-modifiedAt="{ item }">
+        {{ formatDate(item.modifiedAt) }}
+      </template>
+      <template #item-actions="{ item }">
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="cursor-pointer"
+          @click="deleteTable(item)"
+        />
+      </template>
+    </ui-table>
+  </div>
   <storage-edit-table v-model="state.showAddTable" @save="saveTable" />
 </template>
 <script setup>
@@ -68,17 +75,24 @@ const tableHeaders = [
     text: t('common.name'),
     attrs: {
       class: 'w-4/12',
+      style: 'min-width: 120px',
     },
   },
   {
     align: 'center',
     value: 'createdAt',
     text: t('storage.table.createdAt'),
+    attrs: {
+      style: 'min-width: 200px',
+    },
   },
   {
     align: 'center',
     value: 'modifiedAt',
     text: t('storage.table.modifiedAt'),
+    attrs: {
+      style: 'min-width: 200px',
+    },
   },
   {
     value: 'rowsCount',

+ 6 - 1
src/components/newtab/storage/StorageVariables.vue

@@ -6,7 +6,12 @@
       prepend-icon="riSearch2Line"
     />
     <div class="flex-grow"></div>
-    <ui-button variant="accent" @click="editState.show = true">
+    <ui-button
+      variant="accent"
+      style="min-width: 125px"
+      class="ml-4"
+      @click="editState.show = true"
+    >
       Add variable
     </ui-button>
   </div>

+ 1 - 1
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -56,7 +56,7 @@
       shortcut['action:search'].readable
     })`"
     prepend-icon="riSearch2Line"
-    class="px-4 mt-4 mb-2"
+    class="px-4 mt-4 mb-2 w-full"
   />
   <div class="scroll bg-scroll px-4 flex-1 relative overflow-auto">
     <workflow-block-list

+ 2 - 21
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -9,6 +9,7 @@
       <p class="font-semibold inline-block capitalize">
         {{ getBlockName() }}
       </p>
+      <div class="flex-grow"></div>
       <a
         :title="t('common.docs')"
         :href="`https://docs.automa.site/blocks/${data.id}.html`"
@@ -16,21 +17,8 @@
         target="_blank"
         class="text-gray-600 dark:text-gray-200"
       >
-        <v-remixicon name="riInformationLine" size="20" />
+        <v-remixicon name="riInformationLine" />
       </a>
-      <div class="flex-grow"></div>
-      <ui-switch
-        v-if="data.id !== 'trigger'"
-        v-tooltip="
-          t(
-            `workflow.blocks.base.toggle.${
-              blockData.disableBlock ? 'enable' : 'disable'
-            }`
-          )
-        "
-        :model-value="!blockData.disableBlock"
-        @change="$emit('update', { ...blockData, disableBlock: !$event })"
-      />
     </div>
     <component
       :is="getEditComponent()"
@@ -44,19 +32,12 @@
         connections: data.id === 'wait-connections' ? data.connections : null,
       }"
     />
-    <edit-block-settings
-      :key="data.itemId || data.blockId"
-      :data="data"
-      class="mt-4"
-      @change="$emit('update', { ...blockData, ...$event })"
-    />
   </div>
 </template>
 <script setup>
 import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import customEditComponents from '@business/blocks/editComponents';
-import EditBlockSettings from './edit/EditBlockSettings.vue';
 
 const editComponents = require.context(
   './edit',

+ 44 - 9
src/components/newtab/workflow/WorkflowEditor.vue

@@ -10,10 +10,14 @@
     }"
   >
     <Background />
-    <MiniMap v-if="minimap" :node-class-name="minimapNodeClassName" />
+    <MiniMap
+      v-if="minimap"
+      :node-class-name="minimapNodeClassName"
+      class="hidden md:block"
+    />
     <div
       v-if="editorControls"
-      class="flex items-center absolute w-full p-4 left-0 bottom-0 z-10 pr-60"
+      class="flex items-center absolute w-full p-4 left-0 bottom-0 z-10 md:pr-60"
     >
       <slot name="controls-prepend" />
       <editor-search-blocks :editor="editor" />
@@ -52,6 +56,7 @@
           editor: name === 'node-BlockPackage' ? editor : null,
         }"
         @delete="deleteBlock"
+        @settings="initEditBlockSettings"
         @edit="editBlock(nodeProps, $event)"
         @update="updateBlockData(nodeProps.id, $event)"
       />
@@ -59,23 +64,34 @@
     <template #edge-custom="edgeProps">
       <editor-custom-edge v-bind="edgeProps" />
     </template>
+    <ui-modal
+      v-model="blockSettingsState.show"
+      :title="t('workflow.blocks.base.settings.title')"
+      content-class="max-w-xl modal-block-settings"
+      @close="clearBlockSettings"
+    >
+      <edit-block-settings
+        :data="blockSettingsState.data"
+        @change="updateBlockData(blockSettingsState.data.blockId, $event)"
+      />
+    </ui-modal>
   </vue-flow>
 </template>
 <script setup>
-import { onMounted, onBeforeUnmount, watch, computed } from 'vue';
+import { onMounted, onBeforeUnmount, watch, computed, reactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import {
   VueFlow,
-  MiniMap,
-  Background,
   useVueFlow,
   MarkerType,
   getConnectedEdges,
-} from '@braks/vue-flow';
+} from '@vue-flow/core';
+import { Background, MiniMap } from '@vue-flow/additional-components';
 import cloneDeep from 'lodash.clonedeep';
 import { useStore } from '@/stores/main';
-import { categories } from '@/utils/shared';
 import { getBlocks } from '@/utils/getSharedData';
+import { categories } from '@/utils/shared';
+import EditBlockSettings from './edit/EditBlockSettings.vue';
 import EditorCustomEdge from './editor/EditorCustomEdge.vue';
 import EditorSearchBlocks from './editor/EditorSearchBlocks.vue';
 
@@ -169,6 +185,25 @@ const blocks = getBlocks();
 const settings = store.settings.editor;
 const isDisabled = computed(() => props.options.disabled ?? props.disabled);
 
+const blockSettingsState = reactive({
+  show: false,
+  data: {},
+});
+
+function initEditBlockSettings({ blockId, details, data }) {
+  blockSettingsState.data = {
+    blockId,
+    id: details.id,
+    data: cloneDeep(data),
+  };
+  blockSettingsState.show = true;
+}
+function clearBlockSettings() {
+  Object.assign(blockSettingsState, {
+    data: null,
+    show: false,
+  });
+}
 function minimapNodeClassName({ label }) {
   const { category } = blocks[label];
   const { color } = categories[category];
@@ -260,8 +295,8 @@ onBeforeUnmount(() => {
 });
 </script>
 <style>
-@import '@braks/vue-flow/dist/style.css';
-@import '@braks/vue-flow/dist/theme-default.css';
+@import '@vue-flow/core/dist/style.css';
+@import '@vue-flow/core/dist/theme-default.css';
 
 .control-button {
   @apply p-2 rounded-lg bg-white dark:bg-gray-800 transition-colors;

+ 2 - 2
src/components/newtab/workflow/WorkflowRunning.vue

@@ -42,8 +42,8 @@
 <script setup>
 import browser from 'webextension-polyfill';
 import { useI18n } from 'vue-i18n';
-import { sendMessage } from '@/utils/message';
 import { getBlocks } from '@/utils/getSharedData';
+import { workflowState } from '@/newtab/workflowEngine';
 import dayjs from '@/lib/dayjs';
 
 defineProps({
@@ -70,6 +70,6 @@ function openTab(tabId) {
   browser.tabs.update(tabId, { active: true });
 }
 function stopWorkflow(item) {
-  sendMessage('workflow:stop', item, 'background');
+  workflowState.stop(item);
 }
 </script>

+ 29 - 56
src/components/newtab/workflow/edit/EditBlockSettings.vue

@@ -1,55 +1,28 @@
 <template>
-  <div class="block-settings">
-    <slot
-      name="button"
-      :active="state.onError.enable"
-      :show="() => (state.showModal = true)"
-    >
-      <ui-button
-        :class="{ 'text-primary': state.onError.enable }"
-        @click="state.showModal = true"
-      >
-        <v-remixicon name="riShieldLine" class="-ml-1 mr-2" />
-        <span>
-          {{ t('workflow.blocks.base.settings.title') }}
-        </span>
-      </ui-button>
-    </slot>
-    <ui-modal
-      v-model="state.showModal"
-      :title="t('workflow.blocks.base.settings.title')"
-      content-class="max-w-xl modal-block-settings"
-    >
-      <ui-tabs v-model="state.activeTab" class="-mt-2">
-        <ui-tab v-for="tab in tabs" :key="tab.id" :value="tab.id">
-          {{ tab.name }}
-        </ui-tab>
-      </ui-tabs>
-      <ui-tab-panels
-        v-if="state.retrieved"
-        v-model="state.activeTab"
-        class="mt-4"
-      >
-        <ui-tab-panel value="general">
-          <block-setting-general
-            v-model:data="state.settings"
-            @change="onDataChange('settings', $event)"
-          />
-        </ui-tab-panel>
-        <ui-tab-panel value="on-error">
-          <slot name="on-error">
-            <block-setting-on-error
-              :data="state.onError"
-              @change="onDataChange('onError', $event)"
-            />
-          </slot>
-        </ui-tab-panel>
-        <ui-tab-panel value="lines">
-          <block-setting-lines :block-id="data.blockId" />
-        </ui-tab-panel>
-      </ui-tab-panels>
-    </ui-modal>
-  </div>
+  <ui-tabs v-model="state.activeTab" class="-mt-2">
+    <ui-tab v-for="tab in tabs" :key="tab.id" :value="tab.id">
+      {{ tab.name }}
+    </ui-tab>
+  </ui-tabs>
+  <ui-tab-panels v-if="state.retrieved" v-model="state.activeTab" class="mt-4">
+    <ui-tab-panel value="general">
+      <block-setting-general
+        v-model:data="state.settings"
+        @change="onDataChange('settings', $event)"
+      />
+    </ui-tab-panel>
+    <ui-tab-panel value="on-error">
+      <slot name="on-error">
+        <block-setting-on-error
+          :data="state.onError"
+          @change="onDataChange('onError', $event)"
+        />
+      </slot>
+    </ui-tab-panel>
+    <ui-tab-panel value="lines">
+      <block-setting-lines :block-id="data.blockId" />
+    </ui-tab-panel>
+  </ui-tab-panels>
 </template>
 <script setup>
 import { reactive, onMounted } from 'vue';
@@ -69,13 +42,15 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  show: Boolean,
 });
-const emit = defineEmits(['change']);
+const emit = defineEmits(['change', 'close']);
 
 const { t } = useI18n();
 
 let currActiveTab = 'on-error';
 const browserType = BROWSER_TYPE;
+const isOnErrorSupported = !excludeOnError.includes(props.data.id);
 const supportedBlocks = ['forms', 'event-click', 'trigger-event', 'press-key'];
 const tabs = [
   {
@@ -84,7 +59,6 @@ const tabs = [
   },
   { id: 'lines', name: t('workflow.blocks.base.settings.line.title') },
 ];
-const isOnErrorSupported = !excludeOnError.includes(props.data.id);
 const isDebugSupported =
   browserType !== 'firefox' && supportedBlocks.includes(props.data.id);
 
@@ -112,7 +86,6 @@ const defaultSettings = {
 };
 
 const state = reactive({
-  showModal: false,
   retrieved: false,
   activeTab: currActiveTab,
   onError: defaultSettings.onError,
@@ -120,7 +93,7 @@ const state = reactive({
 });
 
 function onDataChange(key, data) {
-  if (!state.retrieved || !state.showModal) return;
+  if (!state.retrieved) return;
 
   state[key] = data;
   emit('change', { [key]: data });
@@ -141,7 +114,7 @@ onMounted(() => {
 
   setTimeout(() => {
     state.retrieved = true;
-  }, 1000);
+  }, 200);
 });
 </script>
 <style>

+ 40 - 44
src/components/newtab/workflow/edit/EditConditions.vue

@@ -20,52 +20,49 @@
         {{ t('workflow.blocks.conditions.add') }}
       </ui-button>
       <div class="flex-grow"></div>
-      <edit-block-settings
-        :data="{ data, blockId }"
-        on-error-label="Cn conditions not met"
-        @change="updateData($event)"
+      <ui-button
+        v-tooltip:bottom="t('common.settings')"
+        icon
+        @click="state.showSettings = !state.showSettings"
       >
-        <template #button="{ show }">
-          <ui-button v-tooltip:bottom="t('common.settings')" icon @click="show">
-            <v-remixicon
-              :name="state.showSettings ? 'riCloseLine' : 'riSettings3Line'"
-            />
-          </ui-button>
-        </template>
-        <template #on-error>
-          <label class="flex items-center mt-6">
-            <ui-switch
-              :model-value="data.retryConditions"
-              @change="updateData({ retryConditions: $event })"
-            />
-            <span class="ml-2 leading-tight">
-              {{ t('workflow.blocks.conditions.retryConditions') }}
-            </span>
-          </label>
-          <div v-if="data.retryConditions" class="mt-2">
-            <ui-input
-              :model-value="data.retryCount"
-              :title="t('workflow.blocks.element-exists.tryFor.title')"
-              :label="t('workflow.blocks.element-exists.tryFor.label')"
-              class="w-full mb-1"
-              type="number"
-              min="1"
-              @change="updateData({ retryCount: +$event })"
-            />
-            <ui-input
-              :model-value="data.retryTimeout"
-              :label="t('workflow.blocks.element-exists.timeout.label')"
-              :title="t('workflow.blocks.element-exists.timeout.title')"
-              class="w-full"
-              type="number"
-              min="200"
-              @change="updateData({ retryTimeout: +$event })"
-            />
-          </div>
-        </template>
-      </edit-block-settings>
+        <v-remixicon
+          :name="state.showSettings ? 'riCloseLine' : 'riSettings3Line'"
+        />
+      </ui-button>
     </div>
+    <template v-if="state.showSettings">
+      <label class="flex items-center mt-6">
+        <ui-switch
+          :model-value="data.retryConditions"
+          @change="updateData({ retryConditions: $event })"
+        />
+        <span class="ml-2 leading-tight">
+          {{ t('workflow.blocks.conditions.retryConditions') }}
+        </span>
+      </label>
+      <div v-if="data.retryConditions" class="mt-2">
+        <ui-input
+          :model-value="data.retryCount"
+          :title="t('workflow.blocks.element-exists.tryFor.title')"
+          :label="t('workflow.blocks.element-exists.tryFor.label')"
+          class="w-full mb-1"
+          type="number"
+          min="1"
+          @change="updateData({ retryCount: +$event })"
+        />
+        <ui-input
+          :model-value="data.retryTimeout"
+          :label="t('workflow.blocks.element-exists.timeout.label')"
+          :title="t('workflow.blocks.element-exists.timeout.title')"
+          class="w-full"
+          type="number"
+          min="200"
+          @change="updateData({ retryTimeout: +$event })"
+        />
+      </div>
+    </template>
     <draggable
+      v-else
       v-model="conditions"
       item-key="id"
       tag="ui-list"
@@ -129,7 +126,6 @@ import { nanoid } from 'nanoid';
 import Draggable from 'vuedraggable';
 import { sleep } from '@/utils/helper';
 import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
-import EditBlockSettings from './EditBlockSettings.vue';
 
 const props = defineProps({
   data: {

+ 19 - 11
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -10,17 +10,24 @@
         @change="updateData({ description: $event })"
       />
       <slot name="prepend:selector" />
-      <ui-select
-        v-if="!hideSelector"
-        :model-value="data.findBy || 'cssSelector'"
-        :placeholder="t('workflow.blocks.base.findElement.placeholder')"
-        class="w-full mb-2"
-        @change="updateData({ findBy: $event })"
-      >
-        <option v-for="type in selectorTypes" :key="type" :value="type">
-          {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
-        </option>
-      </ui-select>
+      <div v-if="!hideSelector" class="flex items-center mb-2">
+        <ui-select
+          :model-value="data.findBy || 'cssSelector'"
+          :placeholder="t('workflow.blocks.base.findElement.placeholder')"
+          class="flex-1 mr-2"
+          @change="updateData({ findBy: $event })"
+        >
+          <option v-for="type in selectorTypes" :key="type" :value="type">
+            {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
+          </option>
+        </ui-select>
+        <SharedElSelectorActions
+          :find-by="data.findBy"
+          :selector="data.selector"
+          :multiple="data.multiple"
+          @update:selector="updateData({ selector: $event })"
+        />
+      </div>
       <edit-autocomplete v-if="!hideSelector" class="mb-1">
         <ui-input
           v-if="!hideSelector"
@@ -88,6 +95,7 @@
 <script setup>
 import { onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
+import SharedElSelectorActions from '@/components/newtab/shared/SharedElSelectorActions.vue';
 import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({

+ 50 - 25
src/components/newtab/workflow/edit/EditJavascriptCode.vue

@@ -7,15 +7,31 @@
       class="w-full mb-1"
       @change="updateData({ description: $event })"
     />
-    <ui-input
-      v-if="!data.everyNewTab"
-      :model-value="data.timeout"
-      :label="t('workflow.blocks.javascript-code.timeout.placeholder')"
-      :title="t('workflow.blocks.javascript-code.timeout.title')"
-      type="number"
-      class="mb-2 w-full"
-      @change="updateData({ timeout: +$event })"
-    />
+    <template v-if="!data.everyNewTab">
+      <ui-input
+        :model-value="data.timeout"
+        :label="t('workflow.blocks.javascript-code.timeout.placeholder')"
+        :title="t('workflow.blocks.javascript-code.timeout.title')"
+        type="number"
+        class="mb-2 w-full"
+        @change="updateData({ timeout: +$event })"
+      />
+      <ui-select
+        v-if="!isFirefox"
+        :model-value="data.context"
+        :label="t('workflow.blocks.javascript-code.context.name')"
+        class="mb-2 w-full"
+        @change="updateData({ context: $event })"
+      >
+        <option
+          v-for="item in ['website', 'background']"
+          :key="item"
+          :value="item"
+        >
+          {{ t(`workflow.blocks.javascript-code.context.items.${item}`) }}
+        </option>
+      </ui-select>
+    </template>
     <p class="text-sm ml-1 text-gray-600 dark:text-gray-200">
       {{ t('workflow.blocks.javascript-code.name') }}
     </p>
@@ -25,21 +41,23 @@
       @click="state.showCodeModal = true"
       v-text="data.code"
     />
-    <ui-checkbox
-      :model-value="data.everyNewTab"
-      class="mt-2"
-      @change="updateData({ everyNewTab: $event })"
-    >
-      {{ t('workflow.blocks.javascript-code.everyNewTab') }}
-    </ui-checkbox>
-    <ui-checkbox
-      :model-value="data.runBeforeLoad"
-      class="mt-2"
-      @change="updateData({ runBeforeLoad: $event })"
-    >
-      Run before page loaded
-    </ui-checkbox>
-    <ui-modal v-model="state.showCodeModal" content-class="max-w-3xl">
+    <template v-if="isFirefox || data.context !== 'background'">
+      <ui-checkbox
+        :model-value="data.everyNewTab"
+        class="mt-2"
+        @change="updateData({ everyNewTab: $event })"
+      >
+        {{ t('workflow.blocks.javascript-code.everyNewTab') }}
+      </ui-checkbox>
+      <ui-checkbox
+        :model-value="data.runBeforeLoad"
+        class="mt-2"
+        @change="updateData({ runBeforeLoad: $event })"
+      >
+        Run before page loaded
+      </ui-checkbox>
+    </template>
+    <ui-modal v-model="state.showCodeModal" content-class="max-w-4xl">
       <template #header>
         <ui-tabs v-model="state.activeTab" class="border-none">
           <ui-tab value="code">
@@ -110,7 +128,9 @@
               class="flex-1 mr-4"
             />
             <ui-checkbox
-              v-if="!data.everyNewTab"
+              v-if="
+                (!data.everyNewTab || data.context !== 'website') && !isFirefox
+              "
               v-model="state.preloadScripts[index].removeAfterExec"
             >
               {{ t('workflow.blocks.javascript-code.removeAfterExec') }}
@@ -157,6 +177,7 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
+const isFirefox = BROWSER_TYPE === 'firefox';
 const availableFuncs = [
   { name: 'automaNextBlock(data, insert?)', id: 'automanextblock-data' },
   { name: 'automaRefData(keyword, path?)', id: 'automarefdata-keyword-path' },
@@ -164,6 +185,10 @@ const availableFuncs = [
     name: 'automaSetVariable(name, value)',
     id: 'automasetvariable-name-value',
   },
+  {
+    name: 'automaFetch(type, resource)',
+    id: 'automasetvariable-type-resource',
+  },
   { name: 'automaResetTimeout()', id: 'automaresettimeout' },
 ];
 const autocompleteList = Object.values(automaFuncsSnippets).slice(0, 4);

+ 8 - 2
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -40,15 +40,20 @@
       @change="updateData({ variableName: $event })"
     />
     <template v-else-if="data.loopThrough === 'elements'">
-      <edit-autocomplete class="mt-2">
+      <edit-autocomplete class="mt-2" trigger-class="!flex items-end">
         <ui-input
           :model-value="data.elementSelector"
           :label="t('workflow.blocks.base.selector')"
           autocomplete="off"
           placeholder="CSS Selector or XPath"
-          class="w-full"
+          class="flex-1 mr-2"
           @change="updateData({ elementSelector: $event })"
         />
+        <shared-el-selector-actions
+          :multiple="true"
+          :selector="data.elementSelector"
+          @update:selector="updateData({ elementSelector: $event })"
+        />
       </edit-autocomplete>
       <ui-checkbox
         :model-value="data.waitForSelector"
@@ -179,6 +184,7 @@ import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import Papa from 'papaparse';
 import { openFilePicker } from '@/utils/helper';
+import SharedElSelectorActions from '@/components/newtab/shared/SharedElSelectorActions.vue';
 import EditAutocomplete from './EditAutocomplete.vue';
 
 const SharedCodemirror = defineAsyncComponent(() =>

+ 20 - 7
src/components/newtab/workflow/edit/EditLoopElements.vue

@@ -42,14 +42,25 @@
           {{ t(`workflow.blocks.loop-elements.actions.${action}`) }}
         </option>
       </ui-select>
-      <ui-input
+      <edit-autocomplete
         v-if="['click-element', 'click-link'].includes(data.loadMoreAction)"
-        :model-value="data.actionElSelector"
-        :label="t('workflow.blocks.base.selector')"
-        placeholder="CSS Selector or XPath"
-        class="mt-2 w-full"
-        @change="updateData({ actionElSelector: $event })"
-      />
+        block
+        class="mt-2"
+        trigger-class="!flex items-end"
+      >
+        <ui-input
+          :model-value="data.actionElSelector"
+          :label="t('workflow.blocks.base.selector')"
+          placeholder="CSS Selector or XPath"
+          class="mr-2 flex-1"
+          autocomplete="off"
+          @change="updateData({ actionElSelector: $event })"
+        />
+        <shared-el-selector-actions
+          :selector="data.actionElSelector"
+          @update:selector="updateData({ actionElSelector: $event })"
+        />
+      </edit-autocomplete>
       <ui-input
         v-if="['click-element', 'scroll'].includes(data.loadMoreAction)"
         :model-value="data.actionElMaxWaitTime"
@@ -83,6 +94,8 @@
 import { onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid/non-secure';
+import SharedElSelectorActions from '@/components/newtab/shared/SharedElSelectorActions.vue';
+import EditAutocomplete from './EditAutocomplete.vue';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({

+ 7 - 2
src/components/newtab/workflow/edit/EditPressKey.vue

@@ -6,15 +6,19 @@
       :placeholder="t('common.description')"
       @change="updateData({ description: $event })"
     />
-    <edit-autocomplete class="mt-2">
+    <edit-autocomplete class="mt-2" trigger-class="!flex items-end">
       <ui-input
         :model-value="data.selector"
-        class="w-full"
+        class="flex-1 mr-2"
         autocomplete="off"
         label="Target element (Optional)"
         placeholder="CSS Selector or XPath"
         @change="updateData({ selector: $event })"
       />
+      <shared-el-selector-actions
+        :selector="data.selector"
+        @update:selector="updateData({ selector: $event })"
+      />
     </edit-autocomplete>
     <ui-select
       :model-value="data.action || 'press-key'"
@@ -78,6 +82,7 @@ import { ref, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { keyDefinitions } from '@/utils/USKeyboardLayout';
 import { recordPressedKey } from '@/utils/recordKeys';
+import SharedElSelectorActions from '@/components/newtab/shared/SharedElSelectorActions.vue';
 import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({

+ 7 - 1
src/components/newtab/workflow/edit/EditSwitchTo.vue

@@ -25,20 +25,26 @@
       block
       hide-empty
       class="mt-2"
+      trigger-class="!flex items-end"
     >
       <ui-input
         :model-value="data.selector"
         :label="t('workflow.blocks.switch-to.iframeSelector')"
         placeholder="CSS Selector or XPath"
         autocomplete="off"
-        class="mb-1 w-full"
+        class="w-full mr-2"
         @change="updateData({ selector: $event })"
       />
+      <shared-el-selector-actions
+        :selector="data.selector"
+        @update:selector="updateData({ selector: $event })"
+      />
     </edit-autocomplete>
   </div>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import SharedElSelectorActions from '@/components/newtab/shared/SharedElSelectorActions.vue';
 import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({

+ 1 - 1
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -138,7 +138,7 @@
       />
       <div class="mt-3">
         <a
-          href="https://docs.automa.site/api-reference/reference-data.html"
+          href="https://docs.automa.site/workflow/expressions.html"
           rel="noopener"
           class="border-b text-primary"
           target="_blank"

+ 9 - 0
src/components/newtab/workflow/edit/EditWorkflowParameters.vue

@@ -133,6 +133,7 @@ import cloneDeep from 'lodash.clonedeep';
 import workflowParameters from '@business/parameters';
 import Draggable from 'vuedraggable';
 import ParameterInputValue from './Parameter/ParameterInputValue.vue';
+import ParameterJsonValue from './Parameter/ParameterJsonValue.vue';
 import ParameterInputOptions from './Parameter/ParameterInputOptions.vue';
 
 const props = defineProps({
@@ -165,6 +166,14 @@ const paramTypes = {
       required: false,
     },
   },
+  json: {
+    id: 'json',
+    name: 'Input (JSON)',
+    valueComp: ParameterJsonValue,
+    data: {
+      required: false,
+    },
+  },
   ...customParameters,
 };
 const paramTypesArr = Object.values(paramTypes)

+ 28 - 0
src/components/newtab/workflow/edit/Parameter/ParameterJsonValue.vue

@@ -0,0 +1,28 @@
+<template>
+  <label>
+    <span v-if="!editor" class="text-gray-600 dark:text-gray-200 text-sm ml-1">
+      {{ paramData.name }}
+    </span>
+    <ui-textarea
+      :model-value="modelValue"
+      type="text"
+      class="w-full"
+      :placeholder="paramData.placeholder"
+      @change="$emit('update:modelValue', $event)"
+    />
+  </label>
+</template>
+<script setup>
+defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  paramData: {
+    type: Object,
+    default: () => ({}),
+  },
+  editor: Boolean,
+});
+defineEmits(['update:modelValue']);
+</script>

+ 1 - 1
src/components/newtab/workflow/editor/EditorCustomEdge.vue

@@ -25,7 +25,7 @@ import {
   getSmoothStepPath,
   getEdgeCenter,
   EdgeText,
-} from '@braks/vue-flow';
+} from '@vue-flow/core';
 
 const props = defineProps({
   id: {

+ 53 - 17
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -30,7 +30,7 @@
           </p>
           <a
             :title="t('common.docs')"
-            href="https://docs.automa.site/guide/host-workflow.html"
+            href="https://docs.automa.site/workflow/sharing-workflow.html#host-workflow"
             target="_blank"
             class="ml-1"
           >
@@ -89,7 +89,10 @@
       </ui-list>
     </ui-popover>
   </ui-card>
-  <ui-card v-if="canEdit" padding="p-1 ml-4 pointer-events-auto">
+  <ui-card
+    v-if="canEdit"
+    padding="p-1 ml-4 hidden md:block pointer-events-auto"
+  >
     <button
       v-for="item in modalActions"
       :key="item.id"
@@ -101,6 +104,24 @@
     </button>
   </ui-card>
   <ui-card padding="p-1 ml-4 flex items-center pointer-events-auto">
+    <ui-popover v-if="canEdit" class="md:hidden">
+      <template #trigger>
+        <button class="rounded-lg p-2 hoverable">
+          <v-remixicon name="riMore2Line" />
+        </button>
+      </template>
+      <ui-list class="space-y-1 cursor-pointer">
+        <ui-list-item
+          v-for="item in modalActions"
+          :key="item.id"
+          v-close-popover
+          @click="$emit('modal', item.id)"
+        >
+          <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+          {{ item.name }}
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
     <button
       v-if="!workflow.isDisabled"
       v-tooltip.group="
@@ -109,7 +130,7 @@
         })`
       "
       class="hoverable p-2 rounded-lg"
-      @click="executeWorkflow"
+      @click="executeCurrWorkflow"
     >
       <v-remixicon name="riPlayLine" />
     </button>
@@ -137,6 +158,14 @@
         </button>
       </template>
       <ui-list style="min-width: 9rem">
+        <ui-list-item
+          v-close-popover
+          class="cursor-pointer"
+          @click="copyWorkflowId"
+        >
+          <v-remixicon name="riFileCopyLine" class="mr-2 -ml-1" />
+          Copy workflow Id
+        </ui-list-item>
         <ui-list-item
           v-if="isTeam && canEdit"
           v-close-popover
@@ -183,7 +212,7 @@
       v-if="!isTeam"
       :title="shortcuts['editor:save'].readable"
       variant="accent"
-      class="relative"
+      class="relative px-2 md:px-4"
       @click="saveWorkflow"
     >
       <span
@@ -197,8 +226,8 @@
           class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
         ></span>
       </span>
-      <v-remixicon name="riSaveLine" class="mr-2 -ml-1 my-1" />
-      {{ t('common.save') }}
+      <v-remixicon name="riSaveLine" class="md:-ml-1 my-1" />
+      <span class="hidden md:block ml-2">{{ t('common.save') }}</span>
     </ui-button>
     <ui-button
       v-else-if="!canEdit"
@@ -285,7 +314,6 @@ import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import { useToast } from 'vue-toastification';
 import browser from 'webextension-polyfill';
-import { sendMessage } from '@/utils/message';
 import { fetchApi } from '@/utils/api';
 import { useUserStore } from '@/stores/user';
 import { useWorkflowStore } from '@/stores/workflow';
@@ -299,6 +327,7 @@ import { tagColors } from '@/utils/shared';
 import { parseJSON, findTriggerBlock } from '@/utils/helper';
 import { exportWorkflow, convertWorkflow } from '@/utils/workflowData';
 import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import getTriggerText from '@/utils/triggerText';
 import convertWorkflowData from '@/utils/convertWorkflowData';
 import WorkflowShareTeam from '@/components/newtab/workflow/WorkflowShareTeam.vue';
@@ -344,7 +373,7 @@ const shortcuts = useShortcut([
   /* eslint-disable-next-line */
   getShortcut('editor:save', saveWorkflow),
   /* eslint-disable-next-line */
-  getShortcut('editor:execute-workflow', executeWorkflow),
+  getShortcut('editor:execute-workflow', executeCurrWorkflow),
 ]);
 
 const { teamId } = router.currentRoute.value.params;
@@ -372,6 +401,17 @@ const userDontHaveTeamsAccess = computed(() => {
   );
 });
 
+function copyWorkflowId() {
+  navigator.clipboard.writeText(props.workflow.id).catch((error) => {
+    console.error(error);
+
+    const textarea = document.createElement('textarea');
+    textarea.value = props.workflow.id;
+    textarea.select();
+    document.execCommand('copy');
+    textarea.blur();
+  });
+}
 function updateWorkflow(data = {}, changedIndicator = false) {
   let store = null;
 
@@ -405,15 +445,11 @@ function updateWorkflowDescription(value) {
   updateWorkflow(payload);
   state.showEditDescription = false;
 }
-function executeWorkflow() {
-  sendMessage(
-    'workflow:execute',
-    {
-      ...props.workflow,
-      isTesting: props.isDataChanged,
-    },
-    'background'
-  );
+function executeCurrWorkflow() {
+  executeWorkflow({
+    ...props.workflow,
+    isTesting: props.isDataChanged,
+  });
 }
 async function setAsHostWorkflow(isHost) {
   if (!userStore.user) {

+ 17 - 10
src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue

@@ -37,6 +37,7 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  isteam: Boolean,
   packageIo: Boolean,
   isPackage: Boolean,
 });
@@ -45,6 +46,7 @@ const emit = defineEmits([
   'paste',
   'group',
   'ungroup',
+  'recording',
   'saveBlock',
   'duplicate',
   'packageIo',
@@ -109,6 +111,11 @@ const menuItems = {
     event: () => emit('duplicate', ctxData),
     shortcut: getShortcut('editor:duplicate-block').readable,
   },
+  startRecording: {
+    id: 'startRecording',
+    name: 'Record from here',
+    event: () => emit('recording', ctxData),
+  },
   setAsInput: {
     id: 'setAsInput',
     name: 'Set as block input',
@@ -155,9 +162,9 @@ onMounted(() => {
   props.editor.onNodeContextMenu(({ event, node }) => {
     const items = ['copy', 'duplicate', 'saveToFolder', 'delete'];
     if (node.label === 'blocks-group') {
-      items.splice(3, 0, 'ungroup');
+      items.splice(items.indexOf('saveToFolder'), 0, 'ungroup');
     } else if (!excludeGroupBlocks.includes(node.label)) {
-      items.splice(3, 0, 'group');
+      items.splice(items.indexOf('saveToFolder'), 0, 'group');
     }
 
     const currCtxData = {
@@ -166,19 +173,19 @@ onMounted(() => {
       position: { clientX: event.clientX, clientY: event.clientY },
     };
 
-    if (
-      props.isPackage &&
-      props.packageIo &&
-      event.target.closest('[data-handleid]')
-    ) {
+    if (!props.isTeam && event.target.closest('[data-handleid]')) {
       const { handleid, nodeid } = event.target.dataset;
 
       currCtxData.nodeId = nodeid;
       currCtxData.handleId = handleid;
 
-      items.unshift(
-        event.target.classList.contains('source') ? 'setAsOutput' : 'setAsInput'
-      );
+      const isOutput = event.target.classList.contains('source');
+
+      if (props.isPackage && props.packageIo) {
+        items.unshift(isOutput ? 'setAsOutput' : 'setAsInput');
+      } else if (isOutput) {
+        items.splice(3, 0, 'startRecording');
+      }
     }
 
     showCtxMenu(items, event);

+ 1 - 4
src/components/newtab/workflows/WorkflowsHosted.vue

@@ -13,9 +13,9 @@
 import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
-import { sendMessage } from '@/utils/message';
 import { arraySorter } from '@/utils/helper';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 
 const props = defineProps({
@@ -66,7 +66,4 @@ async function deleteWorkflow(workflow) {
     },
   });
 }
-function executeWorkflow(workflow) {
-  sendMessage('workflow:execute', workflow, 'background');
-}
 </script>

+ 17 - 4
src/components/newtab/workflows/WorkflowsLocal.vue

@@ -107,12 +107,12 @@ import SelectionArea from '@viselect/vanilla';
 import browser from 'webextension-polyfill';
 import cloneDeep from 'lodash.clonedeep';
 import { arraySorter } from '@/utils/helper';
-import { sendMessage } from '@/utils/message';
 import { useUserStore } from '@/stores/user';
 import { useDialog } from '@/composable/dialog';
 import { useWorkflowStore } from '@/stores/workflow';
 import { exportWorkflow } from '@/utils/workflowData';
 import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import WorkflowsLocalCard from './WorkflowsLocalCard.vue';
 
 const props = defineProps({
@@ -230,9 +230,6 @@ const pinnedWorkflows = computed(() => {
   });
 });
 
-function executeWorkflow(workflow) {
-  sendMessage('workflow:execute', workflow, 'background');
-}
 function toggleDisableWorkflow({ id, isDisabled }) {
   workflowStore.update({
     id,
@@ -349,6 +346,22 @@ function togglePinWorkflow(workflow) {
 }
 
 const menu = [
+  {
+    id: 'copy-id',
+    name: 'Copy workflow id',
+    icon: 'riFileCopyLine',
+    action: (workflow) => {
+      navigator.clipboard.writeText(workflow.id).catch((error) => {
+        console.error(error);
+
+        const textarea = document.createElement('textarea');
+        textarea.value = workflow.id;
+        textarea.select();
+        document.execCommand('copy');
+        textarea.blur();
+      });
+    },
+  },
   {
     id: 'duplicate',
     name: t('common.duplicate'),

+ 1 - 5
src/components/newtab/workflows/WorkflowsShared.vue

@@ -11,8 +11,8 @@
 <script setup>
 import { computed } from 'vue';
 import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
-import { sendMessage } from '@/utils/message';
 import { arraySorter } from '@/utils/helper';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 
 const props = defineProps({
@@ -42,8 +42,4 @@ const workflows = computed(() => {
     order: props.sort.order,
   });
 });
-
-function executeWorkflow(workflow) {
-  sendMessage('workflow:execute', workflow, 'background');
-}
 </script>

+ 1 - 4
src/components/newtab/workflows/WorkflowsUserTeam.vue

@@ -58,10 +58,10 @@ import { useToast } from 'vue-toastification';
 import { fetchApi } from '@/utils/api';
 import { useUserStore } from '@/stores/user';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
-import { sendMessage } from '@/utils/message';
 import { arraySorter } from '@/utils/helper';
 import { useDialog } from '@/composable/dialog';
 import { tagColors } from '@/utils/shared';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 
 const props = defineProps({
@@ -149,9 +149,6 @@ const workflows = computed(() => {
   });
 });
 
-function executeWorkflow(workflow) {
-  sendMessage('workflow:execute', workflow, 'background');
-}
 function onMenuSelected({ id, data }) {
   if (id === 'delete') {
     dialog.confirm({

+ 1 - 3
src/components/popup/home/HomeTeamWorkflows.vue

@@ -35,6 +35,7 @@ import { useUserStore } from '@/stores/user';
 import { sendMessage } from '@/utils/message';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { tagColors } from '@/utils/shared';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import dayjs from '@/lib/dayjs';
 
 const props = defineProps({
@@ -59,9 +60,6 @@ function openWorkflowPage({ teamId, id }) {
   const url = `/teams/${teamId}/workflows/${id}`;
   sendMessage('open:dashboard', url, 'background');
 }
-function executeWorkflow(workflow) {
-  sendMessage('workflow:execute', workflow, 'background');
-}
 
 onMounted(() => {
   if (!userStore.user?.teams) return;

+ 5 - 5
src/content/blocksHandler/handlerConditions.js

@@ -1,7 +1,7 @@
 import { customAlphabet } from 'nanoid/non-secure';
 import { visibleInViewport, isXPath } from '@/utils/helper';
+import { automaRefDataStr } from '@/newtab/workflowEngine/helper';
 import handleSelector from '../handleSelector';
-import { automaRefDataStr } from '../utils';
 
 const nanoid = customAlphabet('1234567890abcdef', 5);
 
@@ -52,7 +52,8 @@ async function handleConditionElement({ data, type, id, frameSelector }) {
 
   return elementActions[actionType](data);
 }
-function injectJsCode({ data, refData }) {
+
+async function handleConditionCode({ data, refData }) {
   return new Promise((resolve, reject) => {
     const varName = `automa${nanoid()}`;
 
@@ -101,9 +102,8 @@ export default async function (data) {
 
   if (data.type.startsWith('element')) {
     result = await handleConditionElement(data);
-  }
-  if (data.type.startsWith('code')) {
-    result = await injectJsCode(data);
+  } else if (data.type.startsWith('code')) {
+    result = await handleConditionCode(data);
   }
 
   return result;

+ 12 - 35
src/content/blocksHandler/handlerCreateElement.js

@@ -1,8 +1,5 @@
-import { customAlphabet } from 'nanoid/non-secure';
 import handleSelector from '../handleSelector';
-import { automaRefDataStr } from '../utils';
 
-const nanoid = customAlphabet('1234567890abcdef', 5);
 const positions = {
   after: 'beforeend',
   before: 'afterbegin',
@@ -10,22 +7,6 @@ const positions = {
   'prev-sibling': 'beforebegin',
 };
 
-function getAutomaScript(refData) {
-  const varName = `automa${nanoid()}`;
-
-  const str = `
-const ${varName} = ${JSON.stringify(refData)};
-${automaRefDataStr(varName)}
-function automaSetVariable(name, value) {
-  ${varName}.variables[name] = value;
-}
-function automaExecWorkflow(options = {}) {
-  window.dispatchEvent(new CustomEvent('automa:execute-workflow', { detail: options }));
-}
-  `;
-
-  return str;
-}
 function createNode(tag, attrs = {}, content = '') {
   const element = document.createElement(tag);
 
@@ -44,13 +25,6 @@ async function createElement(block) {
   const { data, id } = block;
   const baseId = `automa-${id}`;
 
-  data.preloadScripts.forEach((item) => {
-    const scriptId = `${baseId}-script`;
-    const element = createNode(item.type, { id: scriptId }, item.script);
-
-    document.body.appendChild(element);
-  });
-
   if (data.insertAt === 'replace') {
     const fragments = createNode('template', {}, data.html);
     targetElement.replaceWith(fragments.content);
@@ -63,15 +37,18 @@ async function createElement(block) {
     document.body.appendChild(style);
   }
 
-  if (data.javascript) {
-    const automaScript = `
-      (() => { ${getAutomaScript(block.refData)}\n${data.javascript} })()
-    `;
-    const script = createNode(
-      'script',
-      { id: `${baseId}-javascript` },
-      automaScript
-    );
+  if (data.injectJS) {
+    data.preloadScripts.forEach((item) => {
+      const script = document.createElement(item.type);
+      script.id = `${baseId}-script`;
+      script.textContent = item.script;
+
+      document.body.appendChild(script);
+    });
+
+    const script = document.createElement('script');
+    script.id = `${baseId}-javascript`;
+    script.textContent = `(() => { ${data.automaScript}\n${data.javascript} })()`;
 
     document.body.appendChild(script);
   }

+ 20 - 139
src/content/blocksHandler/handlerJavascriptCode.js

@@ -1,154 +1,35 @@
-import { customAlphabet } from 'nanoid/non-secure';
-import { sendMessage } from '@/utils/message';
-import { automaRefDataStr } from '../utils';
+import { jsContentHandler } from '@/newtab/utils/javascriptBlockUtil';
 
-const nanoid = customAlphabet('1234567890abcdef', 5);
+function javascriptCode({ data, isPreloadScripts, frameSelector }) {
+  if (!isPreloadScripts) return jsContentHandler(...data);
 
-function getAutomaScript(refData, everyNewTab) {
-  const varName = `automa${nanoid()}`;
+  let $documentCtx = document;
 
-  let str = `
-const ${varName} = ${JSON.stringify(refData)};
-${automaRefDataStr(varName)}
-function automaSetVariable(name, value) {
-  ${varName}.variables[name] = value;
-}
-function automaNextBlock(data, insert = true) {
-  document.body.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: { data, insert, refData: ${varName} } }));
-}
-function automaResetTimeout() {
- document.body.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
-}
-  `;
-
-  if (everyNewTab) str = automaRefDataStr(varName);
-
-  return str;
-}
-
-function javascriptCode(block) {
-  const automaScript = block.data.everyNewTab
-    ? ''
-    : getAutomaScript(block.refData, block.data.everyNewTab);
+  if (frameSelector) {
+    const iframeCtx = document.querySelector(frameSelector)?.contentDocument;
+    if (!iframeCtx) return Promise.resolve(false);
 
-  return new Promise((resolve, reject) => {
-    let documentCtx = document;
+    $documentCtx = iframeCtx;
+  }
 
-    if (block.frameSelector) {
-      const iframeCtx = document.querySelector(
-        block.frameSelector
-      )?.contentDocument;
+  data.scripts.forEach((script) => {
+    const scriptAttr = `block--${script.id}`;
 
-      if (!iframeCtx) {
-        reject(new Error('iframe-not-found'));
-        return;
-      }
-
-      documentCtx = iframeCtx;
-    }
-
-    const scriptAttr = `block--${block.id}`;
-    const isScriptExists = documentCtx.querySelector(
+    const isScriptExists = $documentCtx.querySelector(
       `.automa-custom-js[${scriptAttr}]`
     );
 
-    if (isScriptExists) {
-      resolve('');
-      return;
-    }
-
-    const promisePreloadScripts =
-      block.data?.preloadScripts?.map(async (item) => {
-        try {
-          const { protocol } = new URL(item.src);
-          const isValidUrl = /https?/.test(protocol);
-
-          if (!isValidUrl) return null;
-
-          const script = await sendMessage(
-            'fetch:text',
-            item.src,
-            'background'
-          );
-          const scriptEl = documentCtx.createElement('script');
-
-          scriptEl.type = 'text/javascript';
-          scriptEl.innerHTML = script;
-
-          return {
-            ...item,
-            script: scriptEl,
-          };
-        } catch (error) {
-          return null;
-        }
-      }) || [];
+    if (isScriptExists) return;
 
-    Promise.allSettled(promisePreloadScripts).then((result) => {
-      const preloadScripts = result.reduce((acc, { status, value }) => {
-        if (status !== 'fulfilled' || !value) return acc;
+    const scriptEl = $documentCtx.createElement('script');
+    scriptEl.textContent = script.data.code;
+    scriptEl.setAttribute(scriptAttr, '');
+    scriptEl.classList.add('automa-custom-js');
 
-        acc.push(value);
-        documentCtx.body.appendChild(value.script);
-
-        return acc;
-      }, []);
-
-      const script = document.createElement('script');
-
-      script.setAttribute(scriptAttr, '');
-      script.classList.add('automa-custom-js');
-      script.textContent = `(() => {
-        ${automaScript}
-
-        try {
-          ${block.data.code}
-        } catch (error) {
-          console.error(error);
-          automaNextBlock({ $error: true, message: error.message });
-        }
-      })()`;
-
-      if (!block.data.everyNewTab) {
-        let timeout;
-
-        const cleanUp = (detail = {}) => {
-          script.remove();
-          preloadScripts.forEach((item) => {
-            if (item.removeAfterExec) item.script.remove();
-          });
-
-          clearTimeout(timeout);
-
-          resolve({
-            columns: {
-              data: detail?.data,
-              insert: detail?.insert,
-            },
-            variables: detail?.refData?.variables,
-          });
-        };
-
-        documentCtx.body.addEventListener(
-          '__automa-next-block__',
-          ({ detail }) => {
-            cleanUp(detail || {});
-          }
-        );
-        documentCtx.body.addEventListener('__automa-reset-timeout__', () => {
-          clearTimeout(timeout);
-
-          timeout = setTimeout(cleanUp, block.data.timeout);
-        });
-
-        timeout = setTimeout(cleanUp, block.data.timeout);
-      } else {
-        resolve();
-      }
-
-      documentCtx.documentElement.appendChild(script);
-    });
+    $documentCtx.documentElement.appendChild(scriptEl);
   });
+
+  return Promise.resolve(true);
 }
 
 export default javascriptCode;

+ 3 - 1
src/content/blocksHandler/handlerTakeScreenshot.js

@@ -122,6 +122,7 @@ export default async function ({ tabId, options, data: { type, selector } }) {
 
   document.body.classList.add('is-screenshotting');
 
+  const style = injectStyle();
   const canvas = document.createElement('canvas');
   const context = canvas.getContext('2d');
   const maxCanvasSize = BROWSER_TYPE === 'firefox' ? 32767 : 65035;
@@ -137,7 +138,6 @@ export default async function ({ tabId, options, data: { type, selector } }) {
 
   scrollableElement.classList?.add('automa-scrollable-el');
 
-  const style = injectStyle();
   const originalYPosition = window.scrollY;
   let originalScrollHeight = scrollableElement.scrollHeight;
 
@@ -156,6 +156,8 @@ export default async function ({ tabId, options, data: { type, selector } }) {
       else if (position === 'fixed') el.setAttribute('is-fixed', '');
     });
 
+  scrollableElement.scrollTo(0, 0);
+
   let scaleDiff = 1;
   let scrollPosition = 0;
   let canvasAdjusted = false;

+ 1 - 1
src/content/blocksHandler/handlerUploadFile.js

@@ -32,7 +32,7 @@ export default async function (block) {
       const name = file.path?.replace(/^.*[\\/]/, '') || '';
       const blob = await fetch(file.objUrl).then((response) => response.blob());
 
-      URL.revokeObjectURL(file.objUrl);
+      if (file.objUrl.startsWith('blob')) URL.revokeObjectURL(file.objUrl);
 
       fileObject = new File([blob], name, { type: file.type });
     }

+ 60 - 0
src/content/blocksHandler/handlerVerifySelector.js

@@ -0,0 +1,60 @@
+import { sleep } from '@/utils/helper';
+import handleSelector from '../handleSelector';
+
+const SLEEP_TIME = 1700;
+
+async function verifySelector(block) {
+  let elements = await handleSelector(block);
+  if (!elements) {
+    await sleep(SLEEP_TIME);
+    return { notFound: true };
+  }
+
+  if (!block.data.multiple) elements = [elements];
+
+  elements[0].scrollIntoView({
+    block: 'center',
+    inline: 'center',
+    behavior: 'smooth',
+  });
+
+  await sleep(200);
+
+  const divEl = document.createElement('div');
+  divEl.style =
+    'height: 100%; width: 100%; top: 0; left: 0; background-color: rgb(0 0 0 / 0.3); pointer-events: none; position: fixed; z-index: 99999';
+
+  const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+  svgEl.style =
+    'height: 100%; width: 100%; top: 0; left: 0; pointer-events: none; position: relative;';
+
+  divEl.appendChild(svgEl);
+
+  elements.forEach((element) => {
+    const { left, top, width, height } = element.getBoundingClientRect();
+    const rectEl = document.createElementNS(
+      'http://www.w3.org/2000/svg',
+      'rect'
+    );
+
+    rectEl.setAttribute('y', top);
+    rectEl.setAttribute('x', left);
+    rectEl.setAttribute('width', width);
+    rectEl.setAttribute('height', height);
+    rectEl.setAttribute('stroke', '#2563EB');
+    rectEl.setAttribute('stroke-width', '2');
+    rectEl.setAttribute('fill', 'rgba(37, 99, 235, 0.4)');
+
+    svgEl.appendChild(rectEl);
+  });
+
+  document.body.appendChild(divEl);
+
+  await sleep(SLEEP_TIME);
+
+  divEl.remove();
+
+  return { notFound: false };
+}
+
+export default verifySelector;

+ 116 - 57
src/content/commandPalette/App.vue

@@ -9,7 +9,7 @@
       id="workflows-container"
       class="absolute w-full max-w-2xl"
       padding="p-0"
-      style="left: 50%; top: 70px; transform: translateX(-50%)"
+      style="left: 50%; top: 50px; transform: translateX(-50%)"
     >
       <div class="p-4">
         <label
@@ -47,42 +47,37 @@
         </div>
         <template v-else>
           <div v-if="paramsState.active">
-            <div class="p-2 rounded-lg bg-box-transparent">
-              <p class="text-sm text-gray-500">Workflow parameters</p>
-              <div>
-                <span
-                  v-for="(item, index) in paramsState.items"
-                  :key="item.name"
-                  :class="{
-                    'font-semibold': paramsState.activeIndex === index,
-                  }"
+            <ul class="space-y-4 divide-y">
+              <li
+                v-for="(param, paramIdx) in paramsState.items"
+                :key="paramIdx"
+              >
+                <component
+                  :is="paramsList[param.type].valueComp"
+                  v-if="paramsList[param.type]"
+                  v-model="param.value"
+                  :label="param.name"
+                  :param-data="param"
+                  class="w-full"
+                />
+                <ui-input
+                  v-else
+                  v-model="param.value"
+                  :type="param.inputType || param.type"
+                  :label="param.name"
+                  :placeholder="param.placeholder"
+                  class="w-full"
+                  @keyup.enter="runWorkflow(index, workflow)"
+                />
+                <p
+                  v-if="param.description"
+                  title="Description"
+                  class="ml-1 text-sm"
                 >
-                  {{ item.name }};
-                </span>
-              </div>
-            </div>
-            <div class="pl-2 text-gray-500">
-              <p class="mt-2">
-                Example:
-                <span v-for="item in paramsState.items" :key="item.name">
-                  {{ item.placeholder || defaultPlaceholders[item.type] }};
-                </span>
-              </p>
-              <div class="flex items-center mt-4">
-                <p class="flex-1 mr-4">
-                  {{ paramsState.workflow.description }}
-                </p>
-                <p>
-                  Press
-                  <span
-                    class="rounded-md bg-box-transparent p-1 text-gray-600 ml-1 text-xs text-center inline-block border-2 border-gray-300 font-semibold"
-                  >
-                    Escape
-                  </span>
-                  to cancel
+                  {{ param.description }}
                 </p>
-              </div>
-            </div>
+              </li>
+            </ul>
           </div>
           <template v-else>
             <p
@@ -136,12 +131,51 @@
           </template>
         </template>
       </div>
+      <div class="px-4 py-2 flex items-center">
+        <div v-if="paramsState.active" class="pl-2 text-gray-500">
+          <div class="flex items-center">
+            <p class="mr-4">
+              {{ paramsState.workflow.description }}
+            </p>
+            <p>
+              Press
+              <span
+                class="rounded-md bg-box-transparent p-1 text-gray-600 ml-1 text-xs text-center inline-block border-2 border-gray-300 font-semibold"
+              >
+                Escape
+              </span>
+              to cancel
+            </p>
+          </div>
+        </div>
+        <p
+          v-else
+          class="inline-flex items-center cursor-pointer text-gray-600"
+          @click="openDashboard"
+        >
+          Open dashboard
+          <v-remixicon
+            name="riExternalLinkLine"
+            class="inline-block ml-1"
+            size="20"
+          />
+        </p>
+        <div class="flex-grow" />
+        <ui-button
+          v-if="paramsState.active"
+          variant="accent"
+          @click="executeWorkflowWithParams"
+        >
+          Execute
+        </ui-button>
+      </div>
     </ui-card>
   </div>
 </template>
 <script setup>
 import {
   onMounted,
+  reactive,
   onBeforeUnmount,
   shallowReactive,
   watch,
@@ -150,14 +184,26 @@ import {
   inject,
 } from 'vue';
 import browser from 'webextension-polyfill';
+import workflowParameters from '@business/parameters';
 import { sendMessage } from '@/utils/message';
-import { debounce } from '@/utils/helper';
+import { debounce, parseJSON } from '@/utils/helper';
+import ParameterInputValue from '@/components/newtab/workflow/edit/Parameter/ParameterInputValue.vue';
+import ParameterJsonValue from '@/components/newtab/workflow/edit/Parameter/ParameterJsonValue.vue';
+
+const paramsList = {
+  string: {
+    id: 'string',
+    name: 'Input (string)',
+    valueComp: ParameterInputValue,
+  },
+  json: {
+    id: 'json',
+    name: 'Input (JSON)',
+    valueComp: ParameterJsonValue,
+  },
+};
 
 const os = navigator.appVersion.indexOf('Mac') !== -1 ? 'mac' : 'win';
-const defaultPlaceholders = {
-  string: 'Text',
-  number: '123123',
-};
 const logoUrl = browser.runtime.getURL('/icon-128.png');
 
 const inputRef = ref(null);
@@ -168,7 +214,7 @@ const state = shallowReactive({
   shortcutKeys: [],
   selectedIndex: -1,
 });
-const paramsState = shallowReactive({
+const paramsState = reactive({
   items: [],
   workflow: {},
   active: false,
@@ -245,6 +291,7 @@ function executeWorkflow(workflow) {
 
     paramsState.workflow = workflow;
     paramsState.items = triggerData.parameters;
+
     paramsState.active = true;
   } else {
     sendExecuteCommand(workflow);
@@ -254,6 +301,29 @@ function executeWorkflow(workflow) {
   state.query = '';
   paramsState.inputtedVal = '';
 }
+function getParamsValues(params) {
+  const getParamVal = {
+    string: (str) => str,
+    number: (num) => (Number.isNaN(+num) ? 0 : +num),
+    json: (value) => parseJSON(value, null),
+    default: (value) => value,
+  };
+
+  return params.reduce((acc, param) => {
+    const valueFunc =
+      getParamVal[param.type] ||
+      paramsList[param.type]?.getValue ||
+      getParamVal.default;
+    const value = valueFunc(param.value || param.defaultValue);
+    acc[param.name] = value;
+
+    return acc;
+  }, {});
+}
+function executeWorkflowWithParams() {
+  const variables = getParamsValues(paramsState.items);
+  sendExecuteCommand(paramsState.workflow, { data: { variables } });
+}
 function onKeydown(event) {
   const { ctrlKey, altKey, metaKey, key, shiftKey } = event;
 
@@ -306,22 +376,7 @@ function onInputKeydown(event) {
   }
 
   if (key === 'Enter') {
-    if (paramsState.active) {
-      const variables = {};
-      const values = paramsState.inputtedVal.split(';');
-
-      paramsState.items.forEach((item, index) => {
-        let value = values[index] ?? '';
-        if (item.type === 'number') value = +value ?? '';
-
-        variables[item.name] = value;
-      });
-
-      sendExecuteCommand(paramsState.workflow, { data: { variables } });
-
-      return;
-    }
-
+    if (paramsState.active) return;
     executeWorkflow(workflows.value[state.selectedIndex]);
   }
 }
@@ -349,6 +404,9 @@ function onInput(event) {
     state.query = value;
   }
 }
+function openDashboard() {
+  sendMessage('open:dashboard', '', 'background');
+}
 
 watch(inputRef, () => {
   if (!inputRef.value) return;
@@ -413,6 +471,7 @@ onMounted(() => {
   });
 
   window.addEventListener('keydown', onKeydown);
+  Object.assign(paramsList, workflowParameters());
 });
 onBeforeUnmount(() => {
   window.removeEventListener('keydown', onKeydown);

+ 3 - 1
src/content/commandPalette/compsUi.js

@@ -7,17 +7,19 @@ import UiButton from '@/components/ui/UiButton.vue';
 import UiSelect from '@/components/ui/UiSelect.vue';
 import UiSpinner from '@/components/ui/UiSpinner.vue';
 import UiTextarea from '@/components/ui/UiTextarea.vue';
+import UiPopover from '@/components/ui/UiPopover.vue';
 import TransitionExpand from '@/components/transitions/TransitionExpand.vue';
 
 export default function (app) {
   app.component('UiCard', UiCard);
   app.component('UiList', UiList);
-  app.component('UiListItem', UiListItem);
   app.component('UiInput', UiInput);
   app.component('UiButton', UiButton);
   app.component('UiSelect', UiSelect);
+  app.component('UiPopover', UiPopover);
   app.component('UiSpinner', UiSpinner);
   app.component('UiTextarea', UiTextarea);
+  app.component('UiListItem', UiListItem);
   app.component('TransitionExpand', TransitionExpand);
 
   app.directive('autofocus', VAutofocus);

+ 4 - 0
src/content/commandPalette/icons.js

@@ -12,6 +12,8 @@ import {
   riCursorLine,
   riDownloadLine,
   riCommandLine,
+  riExternalLinkLine,
+  riArrowDropDownLine,
 } from 'v-remixicon/icons';
 
 export default {
@@ -28,4 +30,6 @@ export default {
   riCursorLine,
   riDownloadLine,
   riCommandLine,
+  riExternalLinkLine,
+  riArrowDropDownLine,
 };

+ 45 - 11
src/content/elementSelector/App.vue

@@ -23,21 +23,15 @@
         />
       </div>
       <div class="flex px-4 pt-4 items-center">
-        <ui-tabs
-          v-if="false"
-          v-model="mainActiveTab"
-          type="fill"
-          class="main-tab"
-        >
-          <ui-tab value="selector"> Selector </ui-tab>
-          <ui-tab value="workflow"> Workflow </ui-tab>
-        </ui-tabs>
         <p class="text-lg font-semibold">Automa</p>
         <div class="flex-grow"></div>
         <button
           class="mr-2 hoverable p-1 rounded-md transition"
           @mousedown.stop.prevent
-          @click.stop.prevent="state.hide = !state.hide"
+          @click.stop.prevent="
+            state.hide = !state.hide;
+            clearConnectedPort();
+          "
         >
           <v-remixicon :name="state.hide ? 'riEyeOffLine' : 'riEyeLine'" />
         </button>
@@ -61,6 +55,15 @@
           @parent="selectElementPath('up')"
           @child="selectElementPath('down')"
         />
+        <ui-button
+          v-if="state.isSelectBlockElement"
+          :disabled="!state.elSelector"
+          variant="accent"
+          class="w-full mt-4"
+          @click="saveSelector"
+        >
+          Select Element
+        </ui-button>
         <selector-elements-detail
           v-if="
             !state.showSettings &&
@@ -71,6 +74,7 @@
           v-bind="{
             elSelector: state.elSelector,
             selectElements: state.selectElements,
+            hideBlocks: state.isSelectBlockElement,
             selectedElements: state.selectedElements,
           }"
           @highlight="toggleHighlightElement"
@@ -161,6 +165,7 @@ import SelectorElementsDetail from '@/components/content/selector/SelectorElemen
 import getSelectorOptions from './getSelectorOptions';
 import { getElementRect } from '../utils';
 
+let connectedPort = null;
 const originalFontSize = document.documentElement.style.fontSize;
 const selectedElement = {
   path: [],
@@ -171,7 +176,6 @@ const selectedElement = {
 const rootElement = inject('rootElement');
 
 const cardEl = ref('cardEl');
-const mainActiveTab = ref('selector');
 const state = reactive({
   hide: false,
   elSelector: '',
@@ -183,6 +187,7 @@ const state = reactive({
   selectorType: 'css',
   selectedElements: [],
   activeTab: 'attributes',
+  isSelectBlockElement: false,
 });
 const cardRect = reactive({
   x: 0,
@@ -335,12 +340,31 @@ function destroy() {
 
   document.documentElement.style.fontSize = originalFontSize;
 }
+function clearConnectedPort() {
+  if (!connectedPort) return;
+
+  connectedPort = null;
+  state.isSelectBlockElement = false;
+}
+function onVisivilityChange() {
+  if (!connectedPort || document.visibilityState !== 'hidden') return;
+
+  clearConnectedPort();
+}
+function saveSelector() {
+  if (!connectedPort) return;
+
+  connectedPort.postMessage(state.elSelector);
+  clearConnectedPort();
+  destroy();
+}
 function attachListeners() {
   cardElementObserver.observe(cardEl.value);
 
   window.addEventListener('message', onMessage);
   window.addEventListener('mouseup', onMouseup);
   window.addEventListener('mousemove', onMousemove);
+  document.addEventListener('visibilitychange', onVisivilityChange);
 }
 function detachListeners() {
   cardElementObserver.disconnect();
@@ -348,6 +372,7 @@ function detachListeners() {
   window.removeEventListener('message', onMessage);
   window.removeEventListener('mouseup', onMouseup);
   window.removeEventListener('mousemove', onMousemove);
+  document.removeEventListener('visibilitychange', onVisivilityChange);
 }
 
 watch(
@@ -366,6 +391,15 @@ watch(
   { deep: true }
 );
 
+browser.runtime.onConnect.addListener((port) => {
+  clearConnectedPort();
+
+  connectedPort = port;
+  state.isSelectBlockElement = true;
+
+  port.onDisconnect.addListener(clearConnectedPort);
+});
+
 onMounted(() => {
   browser.storage.local.get('selectorSettings').then((storage) => {
     const settings = storage.selectorSettings || {};

+ 1 - 0
src/content/handleSelector.js

@@ -105,5 +105,6 @@ export default async function (
     return elements;
   } catch (error) {
     console.error(error);
+    throw error;
   }
 }

+ 29 - 2
src/content/index.js

@@ -1,9 +1,10 @@
 import browser from 'webextension-polyfill';
-import cloneDeep from 'lodash.clonedeep';
 import { nanoid } from 'nanoid';
+import cloneDeep from 'lodash.clonedeep';
+import findSelector from '@/lib/findSelector';
+import { sendMessage } from '@/utils/message';
 import automa from '@business';
 import FindElement from '@/utils/FindElement';
-import findSelector from '@/lib/findSelector';
 import { toCamelCase, isXPath } from '@/utils/helper';
 import handleSelector from './handleSelector';
 import blocksHandler from './blocksHandler';
@@ -186,6 +187,13 @@ function messageListener({ data, source }) {
     true
   );
 
+  window.isAutomaInjected = true;
+  window.addEventListener('message', messageListener);
+  window.addEventListener('contextmenu', ({ target }) => {
+    contextElement = target;
+    $ctxTextSelection = window.getSelection().toString();
+  });
+
   if (isMainFrame) {
     shortcutListener();
     // window.addEventListener('load', elementObserver);
@@ -271,6 +279,25 @@ function messageListener({ data, source }) {
   });
 })();
 
+window.addEventListener('__automa-fetch__', (event) => {
+  const { id, resource, type } = event.detail;
+  const sendResponse = (payload) => {
+    window.dispatchEvent(
+      new CustomEvent(`__autom-fetch-response-${id}__`, {
+        detail: { id, ...payload },
+      })
+    );
+  };
+
+  sendMessage('fetch', { type, resource }, 'background')
+    .then((result) => {
+      sendResponse({ isError: false, result });
+    })
+    .catch((error) => {
+      sendResponse({ isError: true, result: error.message });
+    });
+});
+
 window.addEventListener('DOMContentLoaded', async () => {
   const link = window.location.pathname;
   const isAutomaWorkflow = /.+\.automa\.json$/.test(link);

+ 11 - 3
src/content/services/recordWorkflow/recordEvents.js

@@ -99,7 +99,11 @@ function onChange({ target }) {
 
   addBlock((recording) => {
     const lastFlow = recording.flows.at(-1);
-    if (block.id === 'upload-file' && lastFlow.id === 'event-click') {
+    if (
+      block.id === 'upload-file' &&
+      lastFlow &&
+      lastFlow.id === 'event-click'
+    ) {
       recording.flows.pop();
     }
 
@@ -160,7 +164,7 @@ async function onKeydown(event) {
       };
 
       const lastFlow = recording.flows.at(-1);
-      if (lastFlow.id === 'press-key') {
+      if (lastFlow && lastFlow.id === 'press-key') {
         if (!lastFlow.groupId) lastFlow.groupId = nanoid();
         block.groupId = lastFlow.groupId;
       }
@@ -247,7 +251,11 @@ const onMessage = debounce(({ data, source }) => {
     });
   }
 
+  if (!frameSelector) return;
+
   const lastFlow = data.recording.flows.at(-1);
+  if (!lastFlow) return;
+
   const lastIndex = data.recording.flows.length - 1;
   data.recording.flows[
     lastIndex
@@ -267,7 +275,7 @@ const onScroll = debounce(({ target }) => {
     const verticalScroll = element.scrollTop || element.scrollY || 0;
     const horizontalScroll = element.scrollLeft || element.scrollX || 0;
 
-    if (lastFlow.id === 'element-scroll') {
+    if (lastFlow && lastFlow.id === 'element-scroll') {
       lastFlow.data.scrollY = verticalScroll;
       lastFlow.data.scrollX = horizontalScroll;
 

+ 5 - 1
src/content/services/webService.js

@@ -89,7 +89,11 @@ window.addEventListener('DOMContentLoaded', async () => {
         }
 
         await browser.storage.local.set({ workflows: workflowsStorage });
-        sendMessage('workflow:added', { workflowId }, 'background');
+        sendMessage(
+          'workflow:added',
+          { workflowId, workflowData },
+          'background'
+        );
       } catch (error) {
         console.error(error);
       }

+ 4 - 0
src/lib/vRemixicon.js

@@ -46,6 +46,7 @@ import {
   riTimerLine,
   riMagicLine,
   riHtml5Line,
+  riToggleFill,
   riToggleLine,
   riFolderLine,
   riGithubFill,
@@ -92,6 +93,7 @@ import {
   riFileListLine,
   riDragDropLine,
   riClipboardLine,
+  riCheckDoubleLine,
   riDoubleQuotesL,
   riLightbulbLine,
   riFolderZipLine,
@@ -179,6 +181,7 @@ export const icons = {
   riTimerLine,
   riMagicLine,
   riHtml5Line,
+  riToggleFill,
   riToggleLine,
   riFolderLine,
   riGithubFill,
@@ -225,6 +228,7 @@ export const icons = {
   riFileListLine,
   riDragDropLine,
   riClipboardLine,
+  riCheckDoubleLine,
   riDoubleQuotesL,
   riLightbulbLine,
   riFolderZipLine,

+ 1 - 0
src/lib/vueI18n.js

@@ -46,6 +46,7 @@ export async function loadLocaleMessages(locale, location) {
   dayjs.locale(locale);
 
   await importLocale('common.json');
+  await importLocale('popup.json', true);
   await importLocale(`${location}.json`, true);
   await importLocale('blocks.json', true);
 

+ 11 - 0
src/locales/en/blocks.json

@@ -18,6 +18,10 @@
         "noPermission": "Automa don't have enough permission to do this action",
         "grantPermission": "Grant permission",
         "action": "Action",
+        "element": {
+          "select": "Select an element",
+          "verify": "Verify selector"
+        },
         "settings": {
           "title": "Block settings",
           "line": {
@@ -593,6 +597,13 @@
         "availabeFuncs": "Available functions:",
         "removeAfterExec": "Remove after block executed",
         "everyNewTab": "Execute every new tab",
+        "context": {
+          "name": "Execution context",
+          "items": {
+            "website": "Active tab",
+            "background": "Background"
+          }
+        },
         "modal": {
           "tabs": {
             "code": "JavaScript code",

+ 27 - 16
src/manifest.chrome.json

@@ -1,13 +1,10 @@
 {
-  "manifest_version": 2,
+  "manifest_version": 3,
   "name": "Automa",
+  "action": {},
   "background": {
-    "scripts": ["background.bundle.js"],
-    "persistent": false
-  },
-  "browser_action": {
-    "default_popup": "popup.html",
-    "default_icon": "icon-128.png"
+    "service_worker": "background.bundle.js",
+    "type": "module"
   },
   "icons": {
     "128": "icon-128.png"
@@ -15,8 +12,8 @@
   "commands": {
     "open-dashboard": {
       "suggested_key": {
-        "default": "Ctrl+Shift+A",
-        "mac": "MacCtrl+Shift+A"
+        "default": "Alt+A",
+        "mac": "Alt+A"
       },
       "description": "Open the Automa dashboard"
     }
@@ -52,15 +49,29 @@
     "alarms",
     "storage",
     "debugger",
+    "scripting",
     "webNavigation",
-    "unlimitedStorage",
+    "unlimitedStorage"
+  ],
+  "host_permissions": [
     "<all_urls>"
   ],
   "web_accessible_resources": [
-    "/elementSelector.css",
-    "/Inter-roman-latin.var.woff2",
-    "/icon-128.png",
-    "/locales/*",
-    "elementSelector.bundle.js"
-  ]
+    {
+      "resources": [
+        "/elementSelector.css",
+        "/Inter-roman-latin.var.woff2",
+        "/icon-128.png",
+        "/locales/*",
+        "elementSelector.bundle.js"
+      ],
+      "matches": ["<all_urls>"]
+    }
+  ],
+  "sandbox": {
+    "pages": ["/sandbox.html"]
+  },
+  "content_security_policy": {
+    "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';"
+  }
 }

+ 2 - 5
src/manifest.firefox.json

@@ -10,10 +10,7 @@
     "scripts": ["background.bundle.js"],
     "persistent": false
   },
-  "browser_action": {
-    "default_popup": "popup.html",
-    "default_icon": "icon-128.png"
-  },
+  "browser_action": {},
   "icons": {
     "128": "icon-128.png"
   },
@@ -58,5 +55,5 @@
     "/locales/*",
     "elementSelector.bundle.js"
   ],
-  "content_security_policy": "script-src 'self' https:; object-src 'self'"
+  "content_security_policy": "script-src 'self' 'unsafe-inline' https:; object-src 'self'"
 }

+ 101 - 22
src/newtab/App.vue

@@ -1,7 +1,7 @@
 <template>
   <template v-if="retrieved">
-    <app-sidebar />
-    <main class="pl-16">
+    <app-sidebar v-if="$route.name !== 'recording'" />
+    <main :class="{ 'pl-16': $route.name !== 'recording' }">
       <router-view />
     </main>
     <ui-dialog>
@@ -45,6 +45,10 @@
         <v-remixicon size="20" name="riCloseLine" />
       </button>
     </div>
+    <shared-permissions-modal
+      v-model="permissionState.showModal"
+      :permissions="permissionState.items"
+    />
   </template>
   <div v-else class="py-8 text-center">
     <ui-spinner color="text-accent" size="28" />
@@ -52,10 +56,11 @@
   <app-survey />
 </template>
 <script setup>
-import { ref } from 'vue';
+import { ref, reactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import { compare } from 'compare-versions';
+import { useHead } from '@vueuse/head';
 import browser from 'webextension-polyfill';
 import { useStore } from '@/stores/main';
 import { useUserStore } from '@/stores/user';
@@ -64,11 +69,12 @@ import { usePackageStore } from '@/stores/package';
 import { useWorkflowStore } from '@/stores/workflow';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useTheme } from '@/composable/theme';
-import { parseJSON } from '@/utils/helper';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
 import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vueI18n';
 import { getUserWorkflows } from '@/utils/api';
+import { getWorkflowPermissions } from '@/utils/workflowData';
+import { sendMessage } from '@/utils/message';
 import automa from '@business';
 import dbLogs from '@/db/logs';
 import dayjs from '@/lib/dayjs';
@@ -77,6 +83,8 @@ import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 import dataMigration from '@/utils/dataMigration';
 import iconFirefox from '@/assets/svg/logoFirefox.svg';
 import iconChrome from '@/assets/svg/logo.svg';
+import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
+import { executeWorkflow } from './workflowEngine';
 
 let icon;
 if (window.location.protocol === 'moz-extension:') {
@@ -106,6 +114,10 @@ theme.init();
 
 const retrieved = ref(false);
 const isUpdated = ref(false);
+const permissionState = reactive({
+  permissions: [],
+  showModal: false,
+});
 
 const currentVersion = browser.runtime.getManifest().version;
 const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
@@ -181,33 +193,88 @@ async function syncHostedWorkflows() {
   await hostedWorkflowStore.fetchWorkflows(hostIds);
 }
 
-window.addEventListener('storage', ({ key, newValue }) => {
-  if (key !== 'workflowState') return;
-
-  const states = parseJSON(newValue, {});
-  workflowStore.states = Object.values(states).filter(
-    ({ isDestroyed }) => !isDestroyed
-  );
-});
-browser.runtime.onMessage.addListener(({ type, data }) => {
-  if (type === 'refresh-packages') {
+const messageEvents = {
+  'refresh-packages': function () {
     packageStore.loadData(true);
-    return;
-  }
-
-  if (type === 'workflow:added') {
+  },
+  'workflow:added': function (data) {
     if (data.source === 'team') {
       teamWorkflowStore.loadData().then(() => {
         router.push(
           `/teams/${data.teamId}/workflows/${data.workflowId}?permission=true`
         );
       });
-    } else {
-      workflowStore.loadData().then(() => {
-        router.push(`/workflows/${data.workflowId}?permission=true`);
-      });
+    } else if (data.workflowData) {
+      workflowStore
+        .insert(data.workflowData, { duplicateId: true })
+        .then(async () => {
+          try {
+            const permissions = await getWorkflowPermissions(data.workflowData);
+            if (permissions.length === 0) return;
+
+            permissionState.items = permissions;
+            permissionState.showModal = true;
+          } catch (error) {
+            console.error(error);
+          }
+        })
+        .catch((error) => {
+          console.error(error);
+        });
     }
+  },
+  'workflow:execute': function ({ data, options = {} }) {
+    executeWorkflow(data, options);
+  },
+};
+
+browser.runtime.onMessage.addListener(({ type, data }) => {
+  if (!type || !messageEvents[type]) return;
+
+  messageEvents[type](data);
+});
+
+useHead(() => {
+  const runningWorkflows = workflowStore.states.length;
+
+  return {
+    title: 'Dashboard',
+    titleTemplate:
+      runningWorkflows > 0
+        ? `%s (${runningWorkflows} Workflows Running) - Automa`
+        : '%s - Automa',
+  };
+});
+
+/* eslint-disable-next-line */
+window.onbeforeunload = () => {
+  const runningWorkflows = workflowStore.states.length;
+  if (window.isDataChanged || runningWorkflows > 0) {
+    return t('message.notSaved');
   }
+};
+window.addEventListener('message', ({ data }) => {
+  if (data?.type !== 'automa-fetch') return;
+
+  const sendResponse = (result) => {
+    const sandbox = document.getElementById('sandbox');
+    sandbox.contentWindow.postMessage(
+      {
+        type: 'fetchResponse',
+        data: result,
+        id: data.data.id,
+      },
+      '*'
+    );
+  };
+
+  sendMessage('fetch', data.data, 'background')
+    .then((result) => {
+      sendResponse({ isError: false, result });
+    })
+    .catch((error) => {
+      sendResponse({ isError: true, result: error.message });
+    });
 });
 
 (async () => {
@@ -215,6 +282,13 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
     const tabs = await browser.tabs.query({
       url: browser.runtime.getURL('/newtab.html'),
     });
+
+    const currentWindow = await browser.windows.getCurrent();
+    if (currentWindow.type !== 'popup') {
+      await browser.tabs.remove([tabs[0].id]);
+      return;
+    }
+
     if (tabs.length > 1) {
       const firstTab = tabs.shift();
       await browser.windows.update(firstTab.windowId, { focused: true });
@@ -252,6 +326,11 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
       syncHostedWorkflows(),
     ]);
 
+    const { isRecording } = await browser.storage.local.get('isRecording');
+    if (isRecording) {
+      router.push('/recording');
+    }
+
     autoDeleteLogs();
   } catch (error) {
     retrieved.value = true;

+ 1 - 0
src/newtab/index.html

@@ -7,5 +7,6 @@
 
   <body>
     <div id="app"></div>
+    <iframe src="/sandbox.html" id="sandbox" style="display: none;"></iframe>
   </body>
 </html>

+ 1 - 0
src/newtab/index.js

@@ -15,6 +15,7 @@ import '../assets/css/flow.css';
 const head = createHead();
 
 createApp(App)
+  .use(head)
   .use(router)
   .use(compsUi)
   .use(pinia)

+ 2 - 1
src/newtab/pages/Logs.vue

@@ -34,7 +34,7 @@
         </template>
       </shared-logs-table>
     </div>
-    <div class="flex items-center justify-between mt-4">
+    <div class="md:flex md:items-center md:justify-between mt-4">
       <div>
         {{ t('components.pagination.text1') }}
         <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
@@ -48,6 +48,7 @@
         v-model="pagination.currentPage"
         :per-page="pagination.perPage"
         :records="filteredLogs.length"
+        class="mt-4 md:mt-0"
       />
     </div>
     <ui-card

+ 23 - 10
src/newtab/pages/Packages.vue

@@ -4,7 +4,7 @@
       {{ $t('common.packages') }}
     </h1>
     <div class="mt-8 flex items-start">
-      <div class="w-60">
+      <div class="w-60 mr-8 hidden lg:block">
         <ui-button
           class="w-full"
           variant="accent"
@@ -25,15 +25,26 @@
           </ui-list-item>
         </ui-list>
       </div>
-      <div class="flex-1 ml-8">
-        <div class="flex items-center">
-          <ui-input
-            v-model="state.query"
-            prepend-icon="riSearch2Line"
-            :placeholder="t('common.search')"
-          />
+      <div class="flex-1">
+        <div class="flex items-center flex-wrap">
+          <div class="w-full flex items-center md:w-auto">
+            <ui-input
+              v-model="state.query"
+              :placeholder="t('common.search')"
+              class="flex-1"
+              prepend-icon="riSearch2Line"
+            />
+            <ui-button
+              variant="accent"
+              class="ml-4 lg:hidden"
+              @click="addState.show = true"
+            >
+              <v-remixicon name="riAddLine" class="mr-2 -ml-1" />
+              <span>{{ t('common.packages') }}</span>
+            </ui-button>
+          </div>
           <div class="flex-grow" />
-          <div class="flex items-center workflow-sort">
+          <div class="flex items-center workflow-sort mt-4 lg:mt-0">
             <ui-button
               icon
               class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
@@ -52,7 +63,9 @@
             </ui-select>
           </div>
         </div>
-        <div class="mt-8 grid gap-4 grid-cols-3 2xl:grid-cols-4">
+        <div
+          class="mt-8 grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
+        >
           <ui-card
             v-for="pkg in packages"
             :key="pkg.id"

+ 275 - 0
src/newtab/pages/Recording.vue

@@ -0,0 +1,275 @@
+<template>
+  <div class="p-5 w-full max-w-xl mx-auto">
+    <div class="flex items-center">
+      <button
+        v-tooltip="t('recording.stop')"
+        class="h-12 w-12 rounded-full focus:ring-0 bg-red-400 relative flex items-center justify-center"
+        @click="stopRecording"
+      >
+        <span
+          class="absolute animate-ping bg-red-400 rounded-full"
+          style="height: 80%; width: 80%; animation-duration: 1.3s"
+        ></span>
+        <ui-spinner v-if="state.isGenerating" color="text-white" />
+        <v-remixicon v-else name="riStopLine" class="z-10 relative" />
+      </button>
+      <div class="ml-4 flex-1 overflow-hidden">
+        <p class="text-sm">{{ t('recording.title') }}</p>
+        <p class="font-semibold text-xl leading-tight text-overflow">
+          {{ state.name }}
+        </p>
+      </div>
+    </div>
+    <p class="font-semibold mt-6 mb-2">Flows</p>
+    <ui-list class="space-y-1">
+      <ui-list-item
+        v-for="(item, index) in state.flows"
+        :key="index"
+        class="group"
+        small
+      >
+        <v-remixicon :name="tasks[item.id].icon" />
+        <div class="overflow-hidden flex-1 mx-2">
+          <p class="leading-tight">
+            {{ t(`workflow.blocks.${item.id}.name`) }}
+          </p>
+          <p
+            :title="item.data.description || item.description"
+            class="text-overflow text-sm leading-tight text-gray-600 dark:text-gray-300"
+          >
+            {{ item.data.description || item.description }}
+          </p>
+        </div>
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="invisible group-hover:visible cursor-pointer"
+          @click="removeBlock(index)"
+        />
+      </ui-list-item>
+    </ui-list>
+  </div>
+</template>
+<script setup>
+import { onMounted, reactive, toRaw, onBeforeUnmount } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { nanoid } from 'nanoid';
+import defu from 'defu';
+import browser from 'webextension-polyfill';
+import { tasks } from '@/utils/shared';
+import { useWorkflowStore } from '@/stores/workflow';
+import RecordWorkflowUtils from '@/newtab/utils/RecordWorkflowUtils';
+
+const browserEvents = {
+  onTabCreated: (event) => RecordWorkflowUtils.onTabCreated(event),
+  onTabsActivated: (event) => RecordWorkflowUtils.onTabsActivated(event),
+  onCommitted: (event) => RecordWorkflowUtils.onWebNavigationCommited(event),
+  onWebNavigationCompleted: (event) =>
+    RecordWorkflowUtils.onWebNavigationCompleted(event),
+};
+
+const { t } = useI18n();
+const router = useRouter();
+const workflowStore = useWorkflowStore();
+
+const state = reactive({
+  name: '',
+  flows: [],
+  activeTab: {},
+  isGenerating: false,
+});
+
+function generateDrawflow(startBlock, startBlockData) {
+  let nextNodeId = nanoid();
+  const triggerId = startBlock?.id || nanoid();
+  let prevNodeId = startBlock?.id || triggerId;
+
+  const nodes = [];
+  const edges = [];
+
+  const addEdge = (data = {}) => {
+    edges.push({
+      ...data,
+      id: nanoid(),
+      class: `source-${data.sourceHandle} targte-${data.targetHandle}`,
+    });
+  };
+  addEdge({
+    source: prevNodeId,
+    target: nextNodeId,
+    targetHandle: `${nextNodeId}-input-1`,
+    sourceHandle: startBlock?.output || `${prevNodeId}-output-1`,
+  });
+
+  if (!startBlock) {
+    nodes.push({
+      position: {
+        x: 50,
+        y: 300,
+      },
+      id: triggerId,
+      label: 'trigger',
+      type: 'BlockBasic',
+      data: tasks.trigger.data,
+    });
+  }
+
+  const position = {
+    y: startBlockData ? startBlockData.position.y + 120 : 300,
+    x: startBlockData ? startBlockData.position.x + 280 : 320,
+  };
+  const groups = {};
+
+  state.flows.forEach((block, index) => {
+    if (block.groupId) {
+      if (!groups[block.groupId]) groups[block.groupId] = [];
+
+      groups[block.groupId].push({
+        id: block.id,
+        itemId: nanoid(),
+        data: defu(block.data, tasks[block.id].data),
+      });
+
+      const nextNodeInGroup = state.flows[index + 1]?.groupId;
+      if (nextNodeInGroup) return;
+
+      block.id = 'blocks-group';
+      block.data = { blocks: groups[block.groupId] };
+
+      delete groups[block.groupId];
+    }
+
+    const node = {
+      id: nextNodeId,
+      label: block.id,
+      type: tasks[block.id].component,
+      data: defu(block.data, tasks[block.id].data),
+      position: JSON.parse(JSON.stringify(position)),
+    };
+
+    prevNodeId = nextNodeId;
+    nextNodeId = nanoid();
+
+    if (index !== state.flows.length - 1) {
+      addEdge({
+        target: nextNodeId,
+        source: prevNodeId,
+        targetHandle: `${nextNodeId}-input-1`,
+        sourceHandle: `${prevNodeId}-output-1`,
+      });
+    }
+
+    const inNewRow = (index + 1) % 5 === 0;
+
+    position.x = inNewRow ? 50 : position.x + 280;
+    position.y = inNewRow ? position.y + 150 : position.y;
+
+    nodes.push(node);
+  });
+
+  return {
+    edges,
+    nodes,
+  };
+}
+async function stopRecording() {
+  if (state.isGenerating) return;
+
+  try {
+    state.isGenerating = true;
+
+    if (state.flows.length !== 0) {
+      if (state.workflowId) {
+        const workflow = workflowStore.getById(state.workflowId);
+        const startBlock = workflow.drawflow.nodes.find(
+          (node) => node.id === state.connectFrom.id
+        );
+        const updatedDrawflow = generateDrawflow(state.connectFrom, startBlock);
+
+        const drawflow = {
+          ...workflow.drawflow,
+          nodes: [...workflow.drawflow.nodes, ...updatedDrawflow.nodes],
+          edges: [...workflow.drawflow.edges, ...updatedDrawflow.edges],
+        };
+
+        await workflowStore.update({
+          id: state.workflowId,
+          data: { drawflow },
+        });
+      } else {
+        const drawflow = generateDrawflow();
+
+        await workflowStore.insert({
+          drawflow,
+          name: state.name,
+          description: state.description ?? '',
+        });
+      }
+    }
+
+    await browser.storage.local.remove(['isRecording', 'recording']);
+    await (browser.action || browser.browserAction).setBadgeText({ text: '' });
+
+    const tabs = (await browser.tabs.query({})).filter((tab) =>
+      tab.url.startsWith('http')
+    );
+    Promise.allSettled(
+      tabs.map(({ id }) =>
+        browser.tabs.sendMessage(id, { type: 'recording:stop' })
+      )
+    );
+
+    state.isGenerating = false;
+
+    if (state.workflowId) {
+      router.replace(
+        `/workflows/${state.workflowId}?blockId=${state.connectFrom.id}`
+      );
+    } else {
+      router.replace('/');
+    }
+  } catch (error) {
+    state.isGenerating = false;
+    console.error(error);
+  }
+}
+function removeBlock(index) {
+  state.flows.splice(index, 1);
+
+  browser.storage.local.set({ recording: toRaw(state) });
+}
+function onStorageChanged({ recording }) {
+  if (!recording) return;
+
+  Object.assign(state, recording.newValue);
+}
+
+onMounted(async () => {
+  const { recording, isRecording } = await browser.storage.local.get([
+    'recording',
+    'isRecording',
+  ]);
+
+  if (!isRecording && !recording) return;
+
+  browser.storage.onChanged.addListener(onStorageChanged);
+  browser.tabs.onCreated.addListener(browserEvents.onTabCreated);
+  browser.tabs.onActivated.addListener(browserEvents.onTabsActivated);
+  browser.webNavigation.onCommitted.addListener(browserEvents.onCommitted);
+  browser.webNavigation.onCompleted.addListener(
+    browserEvents.onWebNavigationCompleted
+  );
+
+  Object.assign(state, recording);
+});
+onBeforeUnmount(() => {
+  browser.storage.local.onChanged.removeListener(onStorageChanged);
+  browser.storage.onChanged.removeListener(onStorageChanged);
+  browser.tabs.onCreated.removeListener(browserEvents.onTabCreated);
+  browser.tabs.onActivated.removeListener(browserEvents.onTabsActivated);
+  browser.webNavigation.onCommitted.removeListener(browserEvents.onCommitted);
+  browser.webNavigation.onCompleted.removeListener(
+    browserEvents.onWebNavigationCompleted
+  );
+});
+</script>

+ 52 - 44
src/newtab/pages/ScheduledWorkflow.vue

@@ -10,53 +10,59 @@
         :placeholder="t('common.search')"
       />
       <div class="flex-grow" />
-      <ui-button @click="scheduleState.showModal = true">
-        <v-remixicon name="riAddLine" class="-ml mr-2" />
+      <ui-button
+        class="ml-4"
+        style="min-width: 210px"
+        @click="scheduleState.showModal = true"
+      >
+        <v-remixicon name="riAddLine" class="-ml-1 mr-2" />
         Schedule workflow
       </ui-button>
     </div>
-    <ui-table
-      :headers="tableHeaders"
-      :items="triggers"
-      item-key="id"
-      class="w-full mt-8"
-    >
-      <template #item-name="{ item }">
-        <router-link
-          v-if="item.path"
-          :to="item.path"
-          class="block h-full w-full"
-          style="min-height: 20px"
-        >
-          {{ item.name }}
-        </router-link>
-        <span v-else>
-          {{ item.name }}
-        </span>
-      </template>
-      <template #item-schedule="{ item }">
-        <p v-tooltip="{ content: item.scheduleDetail, allowHTML: true }">
-          {{ item.schedule }}
-        </p>
-      </template>
-      <template #item-active="{ item }">
-        <v-remixicon
-          v-if="item.active"
-          class="text-green-500 dark:text-green-400 inline-block"
-          name="riCheckLine"
-        />
-        <span v-else></span>
-      </template>
-      <template #item-action="{ item }">
-        <button
-          v-tooltip="t('scheduledWorkflow.refresh')"
-          class="rounded-md text-gray-600 dark:text-gray-300"
-          @click="refreshSchedule(item.id)"
-        >
-          <v-remixicon name="riRefreshLine" />
-        </button>
-      </template>
-    </ui-table>
+    <div class="overflow-x-auto w-full scroll">
+      <ui-table
+        :headers="tableHeaders"
+        :items="triggers"
+        item-key="id"
+        class="w-full mt-8"
+      >
+        <template #item-name="{ item }">
+          <router-link
+            v-if="item.path"
+            :to="item.path"
+            class="block h-full w-full"
+            style="min-height: 20px"
+          >
+            {{ item.name }}
+          </router-link>
+          <span v-else>
+            {{ item.name }}
+          </span>
+        </template>
+        <template #item-schedule="{ item }">
+          <p v-tooltip="{ content: item.scheduleDetail, allowHTML: true }">
+            {{ item.schedule }}
+          </p>
+        </template>
+        <template #item-active="{ item }">
+          <v-remixicon
+            v-if="item.active"
+            class="text-green-500 dark:text-green-400 inline-block"
+            name="riCheckLine"
+          />
+          <span v-else></span>
+        </template>
+        <template #item-action="{ item }">
+          <button
+            v-tooltip="t('scheduledWorkflow.refresh')"
+            class="rounded-md text-gray-600 dark:text-gray-300"
+            @click="refreshSchedule(item.id)"
+          >
+            <v-remixicon name="riRefreshLine" />
+          </button>
+        </template>
+      </ui-table>
+    </div>
     <ui-modal
       v-model="scheduleState.showModal"
       title="Workflow Triggers"
@@ -163,6 +169,7 @@ const tableHeaders = [
     text: t('common.name'),
     attrs: {
       class: 'w-3/12',
+      style: 'min-width: 200px',
     },
   },
   {
@@ -170,6 +177,7 @@ const tableHeaders = [
     text: t('scheduledWorkflow.schedule.title'),
     attrs: {
       class: 'w-4/12',
+      style: 'min-width: 200px',
     },
   },
   {

+ 16 - 1
src/newtab/pages/Settings.vue

@@ -2,7 +2,7 @@
   <div class="container pt-8 pb-4">
     <h1 class="text-2xl font-semibold mb-10">{{ t('common.settings') }}</h1>
     <div class="flex items-start">
-      <ui-list class="w-64 mr-12 space-y-2 sticky top-8">
+      <ui-list class="w-64 mr-12 hidden md:block space-y-2 sticky top-8">
         <router-link
           v-for="menu in menus"
           :key="menu.id"
@@ -26,6 +26,15 @@
         </router-link>
       </ui-list>
       <div class="settings-content flex-1">
+        <ui-select
+          :model-value="$route.path"
+          class="w-full mb-4 md:hidden"
+          @change="onSelectChanged"
+        >
+          <option v-for="menu in menus" :key="menu.id" :value="menu.path">
+            {{ t(`settings.menu.${menu.id}`) }}
+          </option>
+        </ui-select>
         <router-view />
       </div>
     </div>
@@ -33,8 +42,10 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
 
 const { t } = useI18n();
+const router = useRouter();
 
 const menus = [
   { id: 'general', path: '/settings', icon: 'riSettings3Line' },
@@ -43,4 +54,8 @@ const menus = [
   { id: 'shortcuts', path: '/shortcuts', icon: 'riKeyboardLine' },
   { id: 'about', path: '/about', icon: 'riInformationLine' },
 ];
+
+function onSelectChanged(value) {
+  router.push(value);
+}
 </script>

+ 1 - 1
src/newtab/pages/Storage.vue

@@ -5,7 +5,7 @@
         {{ t('common.storage') }}
       </h1>
       <a
-        href="https://docs.automa.site/guide/storage.html"
+        href="https://docs.automa.site/reference/storage.html"
         title="Docs"
         class="text-gray-600 dark:text-gray-200 ml-2"
         target="_blank"

+ 129 - 36
src/newtab/pages/Workflows.vue

@@ -4,7 +4,7 @@
       {{ t('common.workflow', 2) }}
     </h1>
     <div class="flex items-start mt-8">
-      <div class="w-60 sticky top-8">
+      <div class="w-60 sticky top-8 hidden lg:block">
         <div class="flex w-full">
           <ui-button
             :title="shortcut['action:new'].readable"
@@ -28,6 +28,13 @@
               >
                 {{ t('workflow.import') }}
               </ui-list-item>
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="initRecordWorkflow"
+              >
+                {{ t('home.record.title') }}
+              </ui-list-item>
               <ui-list-item
                 v-close-popover
                 class="cursor-pointer"
@@ -132,42 +139,97 @@
         />
       </div>
       <div
-        class="flex-1 workflows-list ml-8"
+        class="flex-1 workflows-list lg:ml-8"
         style="min-height: calc(100vh - 8rem)"
         @dblclick="clearSelectedWorkflows"
       >
-        <div class="flex items-center">
-          <ui-input
-            id="search-input"
-            v-model="state.query"
-            :placeholder="`${t(`common.search`)}... (${
-              shortcut['action:search'].readable
-            })`"
-            prepend-icon="riSearch2Line"
-          />
+        <div class="flex items-center flex-wrap">
+          <div class="flex items-center w-full md:w-auto">
+            <ui-input
+              id="search-input"
+              v-model="state.query"
+              class="flex-1 md:w-auto"
+              :placeholder="`${t(`common.search`)}... (${
+                shortcut['action:search'].readable
+              })`"
+              prepend-icon="riSearch2Line"
+            />
+            <ui-popover>
+              <template #trigger>
+                <ui-button variant="accent" class="md:hidden ml-4">
+                  <v-remixicon name="riAddLine" class="mr-2 -ml-1" />
+                  <span>{{ t('common.workflow') }}</span>
+                </ui-button>
+              </template>
+              <ui-list class="space-y-1">
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="addWorkflowModal.show = true"
+                >
+                  {{ t('workflow.new') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="openImportDialog"
+                >
+                  {{ t('workflow.import') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="initRecordWorkflow"
+                >
+                  {{ t('home.record.title') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="addHostedWorkflow"
+                >
+                  {{ t('workflow.host.add') }}
+                </ui-list-item>
+              </ui-list>
+            </ui-popover>
+          </div>
           <div class="flex-grow"></div>
-          <span v-tooltip:bottom.group="t('workflow.backupCloud')" class="mr-4">
-            <ui-button tag="router-link" to="/backup" class="inline-block" icon>
-              <v-remixicon name="riUploadCloud2Line" />
-            </ui-button>
-          </span>
-          <div class="flex items-center workflow-sort">
-            <ui-button
-              icon
-              class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
-              @click="
-                state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'
-              "
+          <div class="w-full md:w-auto flex items-center mt-4 md:mt-0">
+            <span
+              v-tooltip:bottom.group="t('workflow.backupCloud')"
+              class="mr-4"
             >
-              <v-remixicon
-                :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
-              />
-            </ui-button>
-            <ui-select v-model="state.sortBy" :placeholder="t('sort.sortBy')">
-              <option v-for="sort in sorts" :key="sort" :value="sort">
-                {{ t(`sort.${sort}`) }}
-              </option>
-            </ui-select>
+              <ui-button
+                tag="router-link"
+                to="/backup"
+                class="inline-block"
+                icon
+              >
+                <v-remixicon name="riUploadCloud2Line" />
+              </ui-button>
+            </span>
+            <div class="flex items-center workflow-sort flex-1">
+              <ui-button
+                icon
+                class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
+                @click="
+                  state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'
+                "
+              >
+                <v-remixicon
+                  :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
+                />
+              </ui-button>
+              <ui-select
+                v-model="state.sortBy"
+                :placeholder="t('sort.sortBy')"
+                class="flex-1"
+              >
+                <option v-for="sort in sorts" :key="sort" :value="sort">
+                  {{ t(`sort.${sort}`) }}
+                </option>
+              </ui-select>
+            </div>
           </div>
         </div>
         <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
@@ -208,7 +270,11 @@
         :placeholder="t('common.name')"
         autofocus
         class="w-full mb-4"
-        @keyup.enter="addWorkflow"
+        @keyup.enter="
+          addWorkflowModal.type === 'manual'
+            ? addWorkflow()
+            : startRecordWorkflow()
+        "
       />
       <ui-textarea
         v-model="addWorkflowModal.description"
@@ -224,8 +290,20 @@
         <ui-button class="w-full" @click="clearAddWorkflowModal">
           {{ t('common.cancel') }}
         </ui-button>
-        <ui-button variant="accent" class="w-full" @click="addWorkflow">
-          {{ t('common.add') }}
+        <ui-button
+          variant="accent"
+          class="w-full"
+          @click="
+            addWorkflowModal.type === 'manual'
+              ? addWorkflow()
+              : startRecordWorkflow()
+          "
+        >
+          {{
+            addWorkflowModal.type === 'manual'
+              ? t('common.add')
+              : t('home.record.button')
+          }}
         </ui-button>
       </div>
     </ui-modal>
@@ -249,6 +327,7 @@ import { useWorkflowStore } from '@/stores/workflow';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
 import { importWorkflow, getWorkflowPermissions } from '@/utils/workflowData';
+import recordWorkflow from '@/newtab/utils/startRecordWorkflow';
 import WorkflowsLocal from '@/components/newtab/workflows/WorkflowsLocal.vue';
 import WorkflowsShared from '@/components/newtab/workflows/WorkflowsShared.vue';
 import WorkflowsHosted from '@/components/newtab/workflows/WorkflowsHosted.vue';
@@ -286,6 +365,7 @@ const state = shallowReactive({
 const addWorkflowModal = shallowReactive({
   name: '',
   show: false,
+  type: 'manual',
   description: '',
 });
 const permissionState = shallowReactive({
@@ -299,9 +379,22 @@ function clearAddWorkflowModal() {
   Object.assign(addWorkflowModal, {
     name: '',
     show: false,
+    type: 'manual',
     description: '',
   });
 }
+function initRecordWorkflow() {
+  addWorkflowModal.show = true;
+  addWorkflowModal.type = 'recording';
+}
+function startRecordWorkflow() {
+  recordWorkflow({
+    name: addWorkflowModal.name,
+    description: addWorkflowModal.description,
+  }).then(() => {
+    router.push('/recording');
+  });
+}
 function updateActiveTab(data = {}) {
   if (data.activeTab !== 'team') data.teamId = '';
 
@@ -422,6 +515,6 @@ onMounted(() => {
   @apply rounded-l-none !important;
 }
 .workflows-container {
-  @apply grid gap-4 grid-cols-3 2xl:grid-cols-4;
+  @apply grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4;
 }
 </style>

+ 2 - 2
src/newtab/pages/logs/Running.vue

@@ -79,8 +79,8 @@ import { computed, watch, shallowRef, onBeforeUnmount } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { countDuration } from '@/utils/helper';
-import { sendMessage } from '@/utils/message';
 import { useWorkflowStore } from '@/stores/workflow';
+import { workflowState } from '@/newtab/workflowEngine';
 import dbLogs from '@/db/logs';
 import dayjs from '@/lib/dayjs';
 import LogsHistory from '@/components/newtab/logs/LogsHistory.vue';
@@ -100,7 +100,7 @@ const running = computed(() =>
 );
 
 function stopWorkflow() {
-  sendMessage('workflow:stop', running.value.id, 'background');
+  workflowState.stop(running.value.id);
 }
 function getBlockPath(blockId) {
   const { workflowId, teamId } = running.value;

+ 23 - 21
src/newtab/pages/storage/Tables.vue

@@ -22,15 +22,15 @@
         <v-remixicon name="riDeleteBin7Line" />
       </ui-button>
     </div>
-    <div class="flex items-center mb-4">
+    <div class="flex items-center flex-wrap mb-4">
       <ui-input
         v-model="state.query"
         :placeholder="t('common.search')"
         prepend-icon="riSearch2Line"
+        class="w-full md:w-auto mb-4 md:mb-0"
       />
       <div class="flex-grow" />
-      <div class="flex-1"></div>
-      <ui-button class="ml-4" @click="editTable">
+      <ui-button class="md:ml-4" @click="editTable">
         <v-remixicon name="riPencilLine" class="mr-2 -ml-1" />
         <span>Edit table</span>
       </ui-button>
@@ -54,27 +54,29 @@
         </ui-list>
       </ui-popover>
     </div>
-    <ui-table
-      :headers="table.header"
-      :items="rows"
-      :search="state.query"
-      item-key="id"
-      class="w-full"
-    >
-      <template #item-action="{ item }">
-        <v-remixicon
-          title="Delete row"
-          class="cursor-pointer"
-          name="riDeleteBin7Line"
-          @click="deleteRow(item)"
-        />
-      </template>
-    </ui-table>
+    <div class="overflow-x-auto w-full scroll">
+      <ui-table
+        :headers="table.header"
+        :items="rows"
+        :search="state.query"
+        item-key="id"
+        class="w-full"
+      >
+        <template #item-action="{ item }">
+          <v-remixicon
+            title="Delete row"
+            class="cursor-pointer"
+            name="riDeleteBin7Line"
+            @click="deleteRow(item)"
+          />
+        </template>
+      </ui-table>
+    </div>
     <div
       v-if="table.body && table.body.length >= 10"
-      class="flex items-center justify-between mt-4"
+      class="flex flex-col md:flex-row md:items-center md:justify-between mt-4"
     >
-      <div>
+      <div class="mb-4 md:mb-0">
         {{ t('components.pagination.text1') }}
         <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
           <option

+ 5 - 5
src/newtab/pages/workflows/Host.vue

@@ -61,7 +61,7 @@
             })`
           "
           class="p-2 hoverable rounded-lg"
-          @click="executeWorkflow"
+          @click="executeCurrWorkflow"
         >
           <v-remixicon name="riPlayLine" />
         </button>
@@ -114,13 +114,13 @@
 import { computed, reactive, onMounted, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRoute, useRouter } from 'vue-router';
-import { sendMessage } from '@/utils/message';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { findTriggerBlock } from '@/utils/helper';
 import convertWorkflowData from '@/utils/convertWorkflowData';
 import { useWorkflowStore } from '@/stores/workflow';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
 import getTriggerText from '@/utils/triggerText';
 import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
@@ -146,7 +146,7 @@ const editorOptions = {
 };
 
 /* eslint-disable-next-line */
-const shortcut = useShortcut('editor:execute-workflow', executeWorkflow);
+const shortcut = useShortcut('editor:execute-workflow', executeCurrWorkflow);
 
 const state = reactive({
   editorKey: 0,
@@ -199,13 +199,13 @@ async function deleteWorkflowHost() {
     },
   });
 }
-function executeWorkflow() {
+function executeCurrWorkflow() {
   const payload = {
     ...workflow.value,
     id: workflowId,
   };
 
-  sendMessage('workflow:execute', payload, 'background');
+  executeWorkflow(payload);
 }
 async function retrieveTriggerText() {
   const triggerBlock = findTriggerBlock(workflow.value.drawflow);

+ 80 - 33
src/newtab/pages/workflows/[id].vue

@@ -2,7 +2,12 @@
   <div v-if="workflow" class="flex h-screen">
     <div
       v-if="state.showSidebar && haveEditAccess"
-      class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
+      :class="
+        editState.editing
+          ? 'absolute h-full w-full md:relative z-50'
+          : 'hidden md:flex'
+      "
+      class="w-80 bg-white dark:bg-gray-800 py-6 border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex-col"
     >
       <workflow-edit-block
         v-if="editState.editing"
@@ -10,7 +15,7 @@
         :workflow="workflow"
         :editor="editor"
         @update="updateBlockData"
-        @close="(editState.editing = false), (editState.blockData = {})"
+        @close="closeEditingCard"
       />
       <workflow-details-card
         v-else
@@ -216,14 +221,16 @@
             v-if="editor"
             :editor="editor"
             :is-package="isPackage"
+            :is-team="isTeamWorkflow"
             :package-io="workflow.settings?.asBlock"
             @group="groupBlocks"
             @ungroup="ungroupBlocks"
+            @packageIo="addPackageIO"
+            @recording="startRecording"
             @copy="copySelectedElements"
             @paste="pasteCopiedElements"
             @saveBlock="initBlockFolder"
             @duplicate="duplicateElements"
-            @packageIo="addPackageIO"
           />
         </ui-tab-panel>
         <ui-tab-panel value="logs" class="mt-24 container">
@@ -310,16 +317,18 @@ import { getWorkflowPermissions } from '@/utils/workflowData';
 import { fetchApi } from '@/utils/api';
 import { getBlocks } from '@/utils/getSharedData';
 import { excludeGroupBlocks } from '@/utils/shared';
-import { functions } from '@/utils/referenceData/mustacheReplacer';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useCommandManager } from '@/composable/commandManager';
 import { debounce, parseJSON, throttle } from '@/utils/helper';
+import { executeWorkflow } from '@/newtab/workflowEngine';
 import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+import functions from '@/newtab/workflowEngine/templating/templatingFunctions';
 import browser from 'webextension-polyfill';
 import dbStorage from '@/db/storage';
 import DroppedNode from '@/utils/editor/DroppedNode';
 import EditorCommands from '@/utils/editor/EditorCommands';
 import convertWorkflowData from '@/utils/convertWorkflowData';
+import startRecordWorkflow from '@/newtab/utils/startRecordWorkflow';
 import extractAutocopmleteData from '@/utils/editor/editorAutocomplete';
 import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
 import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
@@ -372,6 +381,7 @@ const connectedTable = shallowRef(null);
 
 const state = reactive({
   showSidebar: true,
+  sidebarState: true,
   dataChanged: false,
   animateBlocks: false,
   isExecuteCommand: false,
@@ -413,7 +423,7 @@ const workflowModals = {
     width: 'max-w-2xl',
     component: WorkflowDataTable,
     title: t('workflow.table.title'),
-    docs: 'https://docs.automa.site/api-reference/table.html',
+    docs: 'https://docs.automa.site/workflow/table.html',
     events: {
       /* eslint-disable-next-line */
       connect: fetchConnectedTable,
@@ -465,7 +475,7 @@ const workflowModals = {
     icon: 'riDatabase2Line',
     component: WorkflowGlobalData,
     title: t('common.globalData'),
-    docs: 'https://docs.automa.site/api-reference/global-data.html',
+    docs: 'https://docs.automa.site/workflow/global-data.html',
   },
   settings: {
     width: 'max-w-2xl',
@@ -538,14 +548,6 @@ const editorData = computed(() => {
   return workflow.value.drawflow;
 });
 
-provide('workflow', {
-  editState,
-  data: workflow,
-  columns: workflowColumns,
-});
-provide('workflow-editor', editor);
-provide('autocompleteData', autocompleteList);
-
 const updateBlockData = debounce((data) => {
   if (!haveEditAccess.value) return;
   const node = editor.value.getNode.value(editState.blockData.blockId);
@@ -668,8 +670,48 @@ const onEdgesChange = debounce((changes) => {
   // if (command) commandManager.add(command);
 }, 250);
 
-let nodeTargetHandle = null;
+function closeEditingCard() {
+  editState.editing = false;
+  editState.blockData = {};
+
+  state.showSidebar = state.sidebarState;
+}
+async function executeFromBlock(blockId) {
+  try {
+    if (!blockId) return;
+
+    const workflowOptions = { blockId };
+
+    const [tab] = await browser.tabs.query({ active: true, url: '*://*/*' });
+    if (tab) {
+      workflowOptions.tabId = tab.id;
+    }
 
+    executeWorkflow(workflow.value, workflowOptions);
+  } catch (error) {
+    console.error(error);
+  }
+}
+function startRecording({ nodeId, handleId }) {
+  if (state.dataChanged) {
+    alert('Make sure to save the workflow before starting recording');
+    return;
+  }
+
+  const options = {
+    workflowId,
+    name: workflow.value.name,
+    connectFrom: {
+      id: nodeId,
+      output: handleId,
+    },
+  };
+  startRecordWorkflow(options).then(() => {
+    state.dataChanged = false;
+    router.replace('/recording');
+  });
+}
+let nodeTargetHandle = null;
 function onClickEditor({ target }) {
   const targetClass = target.classList;
   const isHandle = targetClass.contains('vue-flow__handle');
@@ -1165,13 +1207,6 @@ function onEditorInit(instance) {
     });
   });
 
-  instance.removeSelectedNodes(
-    instance.getSelectedNodes.value.map(({ id }) => id)
-  );
-  instance.removeSelectedEdges(
-    instance.getSelectedEdges.value.map(({ id }) => id)
-  );
-
   const editorContainer = document.querySelector(
     '.vue-flow__viewport.vue-flow__container'
   );
@@ -1525,12 +1560,29 @@ const shortcut = useShortcut([
   getShortcut('editor:duplicate-block', duplicateElements),
 ]);
 
+provide('workflow-editor', editor);
+provide('autocompleteData', autocompleteList);
+provide('workflow', {
+  editState,
+  data: workflow,
+  columns: workflowColumns,
+});
+provide('workflow-utils', {
+  executeFromBlock,
+});
+
 watch(
   () => state.activeTab,
   (value) => {
     router.replace({ ...route, query: { tab: value } });
   }
 );
+watch(
+  () => state.dataChanged,
+  (isDataChanged) => {
+    window.isDataChanged = isDataChanged && haveEditAccess.value;
+  }
+);
 watch(
   () => route.params.id,
   (value, oldValue) => {
@@ -1559,8 +1611,10 @@ onMounted(() => {
     return null;
   }
 
-  state.showSidebar =
+  const sidebarState =
     JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
+  state.showSidebar = sidebarState;
+  state.sidebarState = sidebarState;
 
   if (!isPackage) {
     const convertedData = convertWorkflowData(workflow.value);
@@ -1584,15 +1638,6 @@ onMounted(() => {
 
   initAutocomplete();
 
-  window.onbeforeunload = () => {
-    if (isPackage && workflow.value.isExternal) return;
-
-    updateHostedWorkflow();
-
-    if (state.dataChanged && haveEditAccess.value) {
-      return t('message.notSaved');
-    }
-  };
   window.addEventListener('keydown', onKeydown);
 });
 onBeforeUnmount(() => {
@@ -1602,8 +1647,10 @@ onBeforeUnmount(() => {
   if (editorContainer)
     editorContainer.removeEventListener('click', onClickEditor);
 
-  window.onbeforeunload = null;
   window.removeEventListener('keydown', onKeydown);
+
+  if (isPackage && workflow.value.isExternal) return;
+  updateHostedWorkflow();
 });
 </script>
 <style>

+ 6 - 0
src/newtab/router.js

@@ -11,6 +11,7 @@ import StorageTables from './pages/storage/Tables.vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
 import LogsRunning from './pages/logs/Running.vue';
+import Recording from './pages/Recording.vue';
 import Settings from './pages/Settings.vue';
 import SettingsIndex from './pages/settings/SettingsIndex.vue';
 import SettingsAbout from './pages/settings/SettingsAbout.vue';
@@ -35,6 +36,11 @@ const routes = [
     path: '/packages',
     component: Packages,
   },
+  {
+    name: 'recording',
+    path: '/recording',
+    component: Recording,
+  },
   {
     name: 'packages-details',
     path: '/packages/:id',

+ 131 - 0
src/newtab/utils/RecordWorkflowUtils.js

@@ -0,0 +1,131 @@
+import browser from 'webextension-polyfill';
+
+const validateUrl = (str) => str?.startsWith('http');
+const isMV2 = browser.runtime.getManifest().manifest_version === 2;
+
+class RecordWorkflowUtils {
+  static async updateRecording(callback) {
+    const { isRecording, recording } = await browser.storage.local.get([
+      'isRecording',
+      'recording',
+    ]);
+
+    if (!isRecording || !recording) return;
+
+    callback(recording);
+
+    await browser.storage.local.set({ recording });
+  }
+
+  static onTabCreated(tab) {
+    this.updateRecording((recording) => {
+      const url = tab.url || tab.pendingUrl;
+      const lastFlow = recording.flows[recording.flows.length - 1];
+      const invalidPrevFlow =
+        lastFlow &&
+        lastFlow.id === 'new-tab' &&
+        !validateUrl(lastFlow.data.url);
+
+      if (!invalidPrevFlow) {
+        const validUrl = validateUrl(url) ? url : '';
+
+        recording.flows.push({
+          id: 'new-tab',
+          data: {
+            url: validUrl,
+            description: tab.title || validUrl,
+          },
+        });
+      }
+
+      recording.activeTab = {
+        url,
+        id: tab.id,
+      };
+
+      browser.storage.local.set({ recording });
+    });
+  }
+
+  static async onTabsActivated({ tabId }) {
+    const { url, id, title } = await browser.tabs.get(tabId);
+
+    if (!validateUrl(url)) return;
+
+    this.updateRecording((recording) => {
+      recording.activeTab = { id, url };
+      recording.flows.push({
+        id: 'switch-tab',
+        description: title,
+        data: {
+          url,
+          matchPattern: url,
+          createIfNoMatch: true,
+        },
+      });
+    });
+  }
+
+  static onWebNavigationCommited({ frameId, tabId, url, transitionType }) {
+    const allowedType = ['link', 'typed'];
+    if (frameId !== 0 || !allowedType.includes(transitionType)) return;
+
+    this.updateRecording((recording) => {
+      if (recording.activeTab.id && tabId !== recording.activeTab.id) return;
+
+      const lastFlow = recording.flows.at(-1) ?? {};
+      const isInvalidNewtabFlow =
+        lastFlow &&
+        lastFlow.id === 'new-tab' &&
+        !validateUrl(lastFlow.data.url);
+
+      if (isInvalidNewtabFlow) {
+        lastFlow.data.url = url;
+        lastFlow.description = url;
+      } else if (validateUrl(url)) {
+        if (lastFlow?.id !== 'link' || !lastFlow.isClickLink) {
+          recording.flows.push({
+            id: 'new-tab',
+            description: url,
+            data: {
+              url,
+              updatePrevTab: recording.activeTab.id === tabId,
+            },
+          });
+        }
+
+        recording.activeTab.id = tabId;
+        recording.activeTab.url = url;
+      }
+    });
+  }
+
+  static async onWebNavigationCompleted({ tabId, url, frameId }) {
+    if (frameId > 0 || !url.startsWith('http')) return;
+
+    try {
+      const { isRecording } = await browser.storage.local.get('isRecording');
+      if (!isRecording) return;
+
+      if (isMV2) {
+        await browser.tabs.executeScript(tabId, {
+          allFrames: true,
+          runAt: 'document_start',
+          file: './recordWorkflow.bundle.js',
+        });
+      } else {
+        await browser.scripting.executeScript({
+          target: {
+            tabId,
+            allFrames: true,
+          },
+          files: ['recordWorkflow.bundle.js'],
+        });
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  }
+}
+
+export default RecordWorkflowUtils;

+ 125 - 0
src/newtab/utils/elementSelector.js

@@ -0,0 +1,125 @@
+import browser from 'webextension-polyfill';
+import { isXPath, sleep } from '@/utils/helper';
+
+const isMV2 = browser.runtime.getManifest().manifest_version === 2;
+
+async function getActiveTab() {
+  const [tab] = await browser.tabs.query({
+    active: true,
+    url: '*://*/*',
+  });
+  if (!tab) throw new Error('No active tab');
+
+  return tab;
+}
+async function makeDashboardFocus() {
+  const [currentTab] = await browser.tabs.query({
+    active: true,
+    currentWindow: true,
+  });
+  await browser.windows.update(currentTab.windowId, {
+    focused: true,
+  });
+}
+
+export async function initElementSelector(tab = null) {
+  let activeTab = tab;
+
+  if (!tab) {
+    const [queryTab] = await browser.tabs.query({
+      active: true,
+      url: '*://*/*',
+    });
+    activeTab = queryTab;
+  }
+
+  const result = await browser.tabs.sendMessage(activeTab.id, {
+    type: 'automa-element-selector',
+  });
+
+  if (!result) {
+    if (isMV2) {
+      await browser.tabs.executeScript(activeTab.id, {
+        allFrames: true,
+        runAt: 'document_start',
+        file: './elementSelector.bundle.js',
+      });
+    } else {
+      await browser.scripting.executeScript({
+        target: {
+          allFrames: true,
+          tabId: activeTab.id,
+        },
+        files: ['./elementSelector.bundle.js'],
+      });
+    }
+  }
+
+  await browser.tabs.update(activeTab.id, { active: true });
+  await browser.windows.update(activeTab.windowId, { focused: true });
+}
+
+async function verifySelector(data) {
+  try {
+    const activeTab = await getActiveTab();
+
+    if (!data.findBy) {
+      data.findBy = isXPath(data.selector) ? 'xpath' : 'cssSelector';
+    }
+
+    await browser.tabs.update(activeTab.id, { active: true });
+    await browser.windows.update(activeTab.windowId, { focused: true });
+
+    const result = await browser.tabs.sendMessage(
+      activeTab.id,
+      {
+        data,
+        isBlock: true,
+        label: 'verify-selector',
+      },
+      { frameId: 0 }
+    );
+
+    return result;
+  } catch (error) {
+    console.error(error);
+    await sleep(1000);
+
+    return { notFound: true };
+  } finally {
+    await makeDashboardFocus();
+  }
+}
+
+async function selectElement(name) {
+  const tab = await getActiveTab();
+
+  await initElementSelector(tab);
+
+  const port = await browser.tabs.connect(tab.id, { name });
+  const getSelector = () => {
+    return new Promise((resolve, reject) => {
+      port.onDisconnect.addListener(() => {
+        reject(new Error('Port closed'));
+      });
+      port.onMessage.addListener(async (message) => {
+        try {
+          makeDashboardFocus();
+        } catch (error) {
+          console.error(error);
+        } finally {
+          resolve(message);
+        }
+      });
+    });
+  };
+
+  const selector = await getSelector();
+
+  return selector;
+}
+
+export default {
+  selectElement,
+  verifySelector,
+};

+ 155 - 0
src/newtab/utils/javascriptBlockUtil.js

@@ -0,0 +1,155 @@
+export function automaFetchClient(id, { type, resource }) {
+  return new Promise((resolve, reject) => {
+    const validType = ['text', 'json', 'base64'];
+    if (!type || !validType.includes(type)) {
+      reject(new Error('The "type" must be "text" or "json"'));
+      return;
+    }
+
+    const eventName = `__autom-fetch-response-${id}__`;
+    const eventListener = ({ detail }) => {
+      if (detail.id !== id) return;
+
+      window.removeEventListener(eventName, eventListener);
+
+      if (detail.isError) {
+        reject(new Error(detail.result));
+      } else {
+        resolve(detail.result);
+      }
+    };
+
+    window.addEventListener(eventName, eventListener);
+    window.dispatchEvent(
+      new CustomEvent(`__automa-fetch__`, {
+        detail: {
+          id,
+          type,
+          resource,
+        },
+      })
+    );
+  });
+}
+
+export function jsContentHandler($blockData, $preloadScripts, $automaScript) {
+  return new Promise((resolve, reject) => {
+    try {
+      let $documentCtx = document;
+
+      if ($blockData.frameSelector) {
+        const iframeCtx = document.querySelector(
+          $blockData.frameSelector
+        )?.contentDocument;
+
+        if (!iframeCtx) {
+          reject(new Error('iframe-not-found'));
+          return;
+        }
+
+        $documentCtx = iframeCtx;
+      }
+
+      const scriptAttr = `block--${$blockData.id}`;
+
+      const isScriptExists = $documentCtx.querySelector(
+        `.automa-custom-js[${scriptAttr}]`
+      );
+      if (isScriptExists) {
+        resolve('');
+        return;
+      }
+
+      const script = document.createElement('script');
+      script.setAttribute(scriptAttr, '');
+      script.classList.add('automa-custom-js');
+      script.textContent = `(() => {
+        ${$automaScript}
+
+        try {
+          ${$blockData.data.code}
+          ${
+            $blockData.data.everyNewTab ||
+            $blockData.data.code.includes('automaNextBlock')
+              ? ''
+              : 'automaNextBlock()'
+          }
+        } catch (error) {
+          console.error(error);
+          ${
+            $blockData.data.everyNewTab
+              ? ''
+              : 'automaNextBlock({ $error: true, message: error.message })'
+          }
+        }
+      })()`;
+
+      const preloadScriptsEl = $preloadScripts.map((item) => {
+        const scriptEl = document.createElement('script');
+        scriptEl.id = item.id;
+        scriptEl.textContent = item.script;
+
+        $documentCtx.head.appendChild(scriptEl);
+
+        return { element: scriptEl, removeAfterExec: item.removeAfterExec };
+      });
+
+      if (!$blockData.data.everyNewTab) {
+        let timeout;
+        let onNextBlock;
+        let onResetTimeout;
+
+        /* eslint-disable-next-line */
+        function cleanUp() {
+          script.remove();
+          preloadScriptsEl.forEach((item) => {
+            if (item.removeAfterExec) item.script.remove();
+          });
+
+          clearTimeout(timeout);
+
+          $documentCtx.body.removeEventListener(
+            '__automa-reset-timeout__',
+            onResetTimeout
+          );
+          $documentCtx.body.removeEventListener(
+            '__automa-next-block__',
+            onNextBlock
+          );
+        }
+
+        onNextBlock = ({ detail }) => {
+          cleanUp(detail || {});
+          resolve({
+            columns: {
+              data: detail?.data,
+              insert: detail?.insert,
+            },
+            variables: detail?.refData?.variables,
+          });
+        };
+        onResetTimeout = () => {
+          clearTimeout(timeout);
+          timeout = setTimeout(cleanUp, $blockData.data.timeout);
+        };
+
+        $documentCtx.body.addEventListener(
+          '__automa-next-block__',
+          onNextBlock
+        );
+        $documentCtx.body.addEventListener(
+          '__automa-reset-timeout__',
+          onResetTimeout
+        );
+
+        timeout = setTimeout(cleanUp, $blockData.data.timeout);
+      } else {
+        resolve();
+      }
+
+      $documentCtx.head.appendChild(script);
+    } catch (error) {
+      console.error(error);
+    }
+  });
+}

+ 66 - 0
src/newtab/utils/startRecordWorkflow.js

@@ -0,0 +1,66 @@
+import browser from 'webextension-polyfill';
+
+const isMV2 = browser.runtime.getManifest().manifest_version === 2;
+
+export default async function (options = {}) {
+  try {
+    const flows = [];
+    const [activeTab] = await browser.tabs.query({
+      active: true,
+      url: '*://*/*',
+    });
+
+    if (activeTab && activeTab.url.startsWith('http')) {
+      flows.push({
+        id: 'new-tab',
+        description: activeTab.url,
+        data: { url: activeTab.url },
+      });
+
+      await browser.windows.update(activeTab.windowId, { focused: true });
+    }
+
+    await browser.storage.local.set({
+      isRecording: true,
+      recording: {
+        flows,
+        name: 'unnamed',
+        activeTab: {
+          id: activeTab?.id,
+          url: activeTab?.url,
+        },
+        ...options,
+      },
+    });
+
+    const action = browser.action || browser.browserAction;
+    await action.setBadgeBackgroundColor({ color: '#ef4444' });
+    await action.setBadgeText({ text: 'rec' });
+
+    const tabs = await browser.tabs.query({});
+    for (const tab of tabs) {
+      if (
+        tab.url.startsWith('http') &&
+        !tab.url.includes('chrome.google.com')
+      ) {
+        if (isMV2) {
+          await browser.tabs.executeScript(tab.id, {
+            allFrames: true,
+            runAt: 'document_start',
+            file: './recordWorkflow.bundle.js',
+          });
+        } else {
+          await browser.scripting.executeScript({
+            target: {
+              tabId: tab.id,
+              allFrames: true,
+            },
+            files: ['recordWorkflow.bundle.js'],
+          });
+        }
+      }
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}

+ 80 - 55
src/background/workflowEngine/engine.js → src/newtab/workflowEngine/WorkflowEngine.js

@@ -1,9 +1,10 @@
-import browser from 'webextension-polyfill';
 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 Worker from './worker';
+import WorkflowWorker from './WorkflowWorker';
 
 let blocks = getBlocks();
 
@@ -16,6 +17,7 @@ class WorkflowEngine {
     this.blocksHandler = blocksHandler;
     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();
@@ -107,7 +109,11 @@ class WorkflowEngine {
         return;
       }
 
-      const triggerBlock = nodes.find((node) => node.label === 'trigger');
+      const triggerBlock = nodes.find((node) => {
+        if (this.options?.blockId) return node.id === this.options.blockId;
+
+        return node.label === 'trigger';
+      });
       if (!triggerBlock) {
         console.error(`${this.workflow.name} doesn't have a trigger block`);
         return;
@@ -116,8 +122,9 @@ class WorkflowEngine {
       blocks = getBlocks();
 
       const checkParams = this.options?.checkParams ?? true;
-      const hasParams = triggerBlock.data.parameters?.length > 0;
-      if (checkParams && hasParams) {
+      const hasParams =
+        checkParams && triggerBlock.data?.parameters?.length > 0;
+      if (hasParams) {
         this.eventListeners = {};
 
         const paramUrl = browser.runtime.getURL('params.html');
@@ -153,8 +160,12 @@ class WorkflowEngine {
       }, {});
       this.connectionsMap = edges.reduce(
         (acc, { sourceHandle, target, targetHandle }) => {
-          if (!acc[sourceHandle]) acc[sourceHandle] = [];
-          acc[sourceHandle].push({ id: target, targetHandle, sourceHandle });
+          if (!acc[sourceHandle]) acc[sourceHandle] = new Map();
+          acc[sourceHandle].set(target, {
+            id: target,
+            targetHandle,
+            sourceHandle,
+          });
 
           return acc;
         },
@@ -250,7 +261,7 @@ class WorkflowEngine {
     this.workerId += 1;
 
     const workerId = `worker-${this.workerId}`;
-    const worker = new Worker(workerId, this, { blocksDetail: blocks });
+    const worker = new WorkflowWorker(workerId, this, { blocksDetail: blocks });
     worker.init(detail);
 
     this.workers.set(worker.id, worker);
@@ -284,7 +295,7 @@ class WorkflowEngine {
           activeTabUrl,
           prevBlockData: detail.prevBlockData || '',
         },
-        replacedValue: detail.replacedValue,
+        replacedValue: cloneDeep(detail.replacedValue),
       };
 
       delete detail.replacedValue;
@@ -307,7 +318,7 @@ class WorkflowEngine {
 
   async executeQueue() {
     const { workflowQueue } = await browser.storage.local.get('workflowQueue');
-    const queueIndex = (workflowQueue || []).indexOf(this.workflow.id);
+    const queueIndex = (workflowQueue || []).indexOf(this.workflow?.id);
 
     if (!workflowQueue || queueIndex === -1) return;
 
@@ -337,6 +348,29 @@ class WorkflowEngine {
   }
 
   async destroy(status, message, blockDetail) {
+    const cleanUp = () => {
+      this.id = null;
+      this.states = null;
+      this.logger = null;
+      this.saveLog = null;
+      this.workflow = null;
+      this.blocksHandler = null;
+      this.parentWorkflow = null;
+
+      this.isDestroyed = true;
+      this.referenceData = null;
+      this.eventListeners = null;
+      this.packagesCache = null;
+      this.extractedGroup = null;
+      this.connectionsMap = null;
+      this.waitConnections = null;
+      this.blocks = null;
+      this.history = null;
+      this.columnsId = null;
+      this.historyCtxData = null;
+      this.preloadScripts = null;
+    };
+
     try {
       if (this.isDestroyed) return;
       if (this.isUsingProxy) browser.proxy.settings.clear({});
@@ -356,39 +390,6 @@ class WorkflowEngine {
       this.workers.clear();
       this.executeQueue();
 
-      if (!this.workflow.isTesting) {
-        const { name, id, teamId } = this.workflow;
-
-        await this.logger.add({
-          detail: {
-            name,
-            status,
-            teamId,
-            message,
-            id: this.id,
-            workflowId: id,
-            endedAt: endedTimestamp,
-            parentLog: this.parentWorkflow,
-            startedAt: this.startedTimestamp,
-          },
-          history: {
-            logId: this.id,
-            data: this.saveLog ? this.history : [],
-          },
-          ctxData: {
-            logId: this.id,
-            data: this.historyCtxData,
-          },
-          data: {
-            logId: this.id,
-            data: {
-              table: [...this.referenceData.table],
-              variables: { ...this.referenceData.variables },
-            },
-          },
-        });
-      }
-
       this.states.off('stop', this.onWorkflowStopped);
       await this.states.delete(this.id);
 
@@ -457,20 +458,44 @@ class WorkflowEngine {
         }
       );
 
-      this.isDestroyed = true;
-      this.referenceData = null;
-      this.eventListeners = null;
-      this.packagesCache = null;
-      this.extractedGroup = null;
-      this.connectionsMap = null;
-      this.waitConnections = null;
-      this.blocks = null;
-      this.history = null;
-      this.columnsId = null;
-      this.historyCtxData = null;
-      this.preloadScripts = null;
+      if (!this.workflow?.isTesting) {
+        const { name, id, teamId } = this.workflow;
+
+        await this.logger.add({
+          detail: {
+            name,
+            status,
+            teamId,
+            message,
+            id: this.id,
+            workflowId: id,
+            saveLog: this.saveLog,
+            endedAt: endedTimestamp,
+            parentLog: this.parentWorkflow,
+            startedAt: this.startedTimestamp,
+          },
+          history: {
+            logId: this.id,
+            data: this.saveLog ? this.history : [],
+          },
+          ctxData: {
+            logId: this.id,
+            data: this.historyCtxData,
+          },
+          data: {
+            logId: this.id,
+            data: {
+              table: [...this.referenceData.table],
+              variables: { ...this.referenceData.variables },
+            },
+          },
+        });
+      }
+
+      cleanUp();
     } catch (error) {
       console.error(error);
+      cleanUp();
     }
   }
 

+ 0 - 4
src/background/WorkflowLogger.js → src/newtab/workflowEngine/WorkflowLogger.js

@@ -1,10 +1,6 @@
 import dbLogs, { defaultLogItem } from '@/db/logs';
 /* eslint-disable class-methods-use-this */
 class WorkflowLogger {
-  constructor({ key = 'logs' }) {
-    this.key = key;
-  }
-
   async add({ detail, history, ctxData, data }) {
     const logDetail = { ...defaultLogItem, ...detail };
 

+ 0 - 0
src/background/WorkflowState.js → src/newtab/workflowEngine/WorkflowState.js


+ 16 - 11
src/background/workflowEngine/worker.js → src/newtab/workflowEngine/WorkflowWorker.js

@@ -1,4 +1,5 @@
 import browser from 'webextension-polyfill';
+import cloneDeep from 'lodash.clonedeep';
 import {
   toCamelCase,
   sleep,
@@ -6,12 +7,12 @@ import {
   parseJSON,
   isObject,
 } from '@/utils/helper';
-import referenceData from '@/utils/referenceData';
-import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
+import templating from './templating';
+import renderString from './templating/renderString';
 import { convertData, waitTabLoaded } from './helper';
 import injectContentScript from './injectContentScript';
 
-class Worker {
+class WorkflowWorker {
   constructor(id, engine, options = {}) {
     this.id = id;
     this.engine = engine;
@@ -92,7 +93,11 @@ class Worker {
     if (this.engine.isDestroyed) return null;
 
     const outputId = `${blockId}-output-${outputIndex}`;
-    return this.engine.connectionsMap[outputId] || null;
+    const connections = this.engine.connectionsMap[outputId];
+
+    if (!connections) return null;
+
+    return [...connections.values()];
   }
 
   executeNextBlocks(connections, prevBlockData) {
@@ -109,7 +114,7 @@ class Worker {
           ...execParam,
         });
       } else {
-        const state = structuredClone({
+        const state = cloneDeep({
           windowId: this.windowId,
           loopList: this.loopList,
           activeTab: this.activeTab,
@@ -167,7 +172,7 @@ class Worker {
       activeTabUrl: this.activeTab.url,
     };
 
-    const replacedBlock = referenceData({
+    const replacedBlock = await templating({
       block,
       data: refData,
       refKeys:
@@ -242,8 +247,8 @@ class Worker {
         }
 
         if (blockOnError.insertData) {
-          blockOnError.dataToInsert.forEach((item) => {
-            let value = mustacheReplacer(item.value, refData)?.value;
+          for (const item of blockOnError.dataToInsert) {
+            let value = await renderString(item.value, refData)?.value;
             value = parseJSON(value, value);
 
             if (item.type === 'variable') {
@@ -251,7 +256,7 @@ class Worker {
             } else {
               this.addDataToColumn(item.name, value);
             }
-          });
+          }
         }
 
         const nextBlocks = this.getBlockConnections(
@@ -296,7 +301,7 @@ class Worker {
         this.reset();
 
         const triggerBlock = this.engine.blocks[this.engine.triggerBlockId];
-        this.executeBlock(triggerBlock, execParam);
+        if (triggerBlock) this.executeBlock(triggerBlock, execParam);
 
         localStorage.setItem(restartKey, restartCount + 1);
       } else {
@@ -405,4 +410,4 @@ class Worker {
   }
 }
 
-export default Worker;
+export default WorkflowWorker;

+ 12 - 5
src/background/workflowEngine/blocksHandler.js → src/newtab/workflowEngine/blocksHandler.js

@@ -1,13 +1,20 @@
 import customHandlers from '@business/blocks/backgroundHandler';
 import { toCamelCase } from '@/utils/helper';
 
-const blocksHandler = require.context('./blocksHandler', false, /\.js$/);
-const handlers = blocksHandler.keys().reduce((acc, key) => {
-  const name = key.replace(/^\.\/handler|\.js/g, '');
+const blocksHandler = require.context(
+  './blocksHandler',
+  false,
+  /\.js$/,
+  'lazy'
+);
+const handlers = {};
 
-  acc[toCamelCase(name)] = blocksHandler(key).default;
+blocksHandler.keys().forEach((key) => {
+  const name = key.replace(/^\.\/handler|\.js/g, '');
 
-  return acc;
+  blocksHandler(key).then((module) => {
+    handlers[toCamelCase(name)] = module.default;
+  });
 }, {});
 
 export default function () {

+ 24 - 6
src/background/workflowEngine/blocksHandler/handlerActiveTab.js → src/newtab/workflowEngine/blocksHandler/handlerActiveTab.js

@@ -1,5 +1,6 @@
 import browser from 'webextension-polyfill';
-import { attachDebugger } from '../helper';
+import { sleep } from '@/utils/helper';
+import { attachDebugger, injectPreloadScript } from '../helper';
 
 async function activeTab(block) {
   try {
@@ -16,7 +17,7 @@ async function activeTab(block) {
 
     const [tab] = await browser.tabs.query({
       active: true,
-      currentWindow: true,
+      url: '*://*/*',
     });
 
     if (!tab?.url.startsWith('http')) {
@@ -40,12 +41,29 @@ async function activeTab(block) {
     }
 
     if (this.preloadScripts.length > 0) {
-      const preloadScripts = this.preloadScripts.map((script) =>
-        this._sendMessageToTab(script)
-      );
-      await Promise.allSettled(preloadScripts);
+      if (this.engine.isMV2) {
+        await this._sendMessageToTab({
+          isPreloadScripts: true,
+          label: 'javascript-code',
+          data: { scripts: this.preloadScripts },
+        });
+      } else {
+        await injectPreloadScript({
+          scripts: this.preloadScripts,
+          frameSelector: this.frameSelector,
+          target: {
+            tabId: this.activeTab.id,
+            frameIds: [this.activeTab.frameId || 0],
+          },
+        });
+      }
     }
 
+    await browser.tabs.update(tab.id, { active: true });
+    await browser.windows.update(tab.windowId, { focused: true });
+
+    await sleep(200);
+
     return data;
   } catch (error) {
     console.error(error);

+ 7 - 4
src/background/workflowEngine/blocksHandler/handlerBlockPackage.js → src/newtab/workflowEngine/blocksHandler/handlerBlockPackage.js

@@ -44,7 +44,7 @@ export default async function (
         this.engine.connectionsMap[`${id}-output-${output.id}`];
       if (!connection) return;
 
-      connections[addBlockPrefix(output.handleId)] = [...connection];
+      connections[addBlockPrefix(output.handleId)] = [...connection.values()];
     });
 
     data.data.nodes.forEach((node) => {
@@ -60,9 +60,12 @@ export default async function (
       if (outputsMap.has(sourceHandle)) return;
 
       const nodeSourceHandle = addBlockPrefix(sourceHandle);
-      if (!connections[nodeSourceHandle]) connections[nodeSourceHandle] = [];
-      connections[nodeSourceHandle].push({
-        id: addBlockPrefix(target),
+      if (!connections[nodeSourceHandle])
+        connections[nodeSourceHandle] = new Map();
+
+      const connectionId = addBlockPrefix(target);
+      connections[nodeSourceHandle].set(connectionId, {
+        id: connectionId,
         sourceHandle: nodeSourceHandle,
         targetHandle: addBlockPrefix(targetHandle),
       });

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff