index.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. import browser from 'webextension-polyfill';
  2. import dayjs from '@/lib/dayjs';
  3. import { MessageListener } from '@/utils/message';
  4. import { parseJSON, findTriggerBlock, sleep } from '@/utils/helper';
  5. import { fetchApi } from '@/utils/api';
  6. import getFile from '@/utils/getFile';
  7. import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
  8. import convertWorkflowData from '@/utils/convertWorkflowData';
  9. import {
  10. registerSpecificDay,
  11. registerContextMenu,
  12. registerWorkflowTrigger,
  13. } from '../utils/workflowTrigger';
  14. import WorkflowState from './WorkflowState';
  15. import CollectionEngine from './collectionEngine';
  16. import WorkflowEngine from './workflowEngine/engine';
  17. import blocksHandler from './workflowEngine/blocksHandler';
  18. import WorkflowLogger from './WorkflowLogger';
  19. const validateUrl = (str) => str?.startsWith('http');
  20. const browserStorage = {
  21. async get(key) {
  22. try {
  23. const result = await browser.storage.local.get(key);
  24. return result[key];
  25. } catch (error) {
  26. console.error(error);
  27. return [];
  28. }
  29. },
  30. async set(key, value) {
  31. await browser.storage.local.set({ [key]: value });
  32. if (key === 'workflowState') {
  33. sessionStorage.setItem(key, JSON.stringify(value));
  34. }
  35. },
  36. };
  37. const localStateStorage = {
  38. get(key) {
  39. const data = parseJSON(localStorage.getItem(key), null);
  40. return data;
  41. },
  42. set(key, value) {
  43. const data = typeof value === 'object' ? JSON.stringify(value) : value;
  44. return localStorage.setItem(key, data);
  45. },
  46. };
  47. const workflow = {
  48. states: new WorkflowState({ storage: localStateStorage }),
  49. logger: new WorkflowLogger({ storage: browserStorage }),
  50. async get(workflowId) {
  51. const { workflows, workflowHosts } = await browser.storage.local.get([
  52. 'workflows',
  53. 'workflowHosts',
  54. ]);
  55. let findWorkflow = Array.isArray(workflows)
  56. ? workflows.find(({ id }) => id === workflowId)
  57. : workflows[workflowId];
  58. if (!findWorkflow) {
  59. findWorkflow = Object.values(workflowHosts || {}).find(
  60. ({ hostId }) => hostId === workflowId
  61. );
  62. if (findWorkflow) findWorkflow.id = findWorkflow.hostId;
  63. }
  64. return findWorkflow;
  65. },
  66. execute(workflowData, options) {
  67. if (workflowData.isDisabled) return null;
  68. if (workflowData.isProtected) {
  69. const flow = parseJSON(workflowData.drawflow, null);
  70. if (!flow) {
  71. const pass = getWorkflowPass(workflowData.pass);
  72. workflowData.drawflow = decryptFlow(workflowData, pass);
  73. }
  74. }
  75. const convertedWorkflow = convertWorkflowData(workflowData);
  76. const engine = new WorkflowEngine(convertedWorkflow, {
  77. options,
  78. blocksHandler,
  79. logger: this.logger,
  80. states: this.states,
  81. });
  82. if (options?.resume) {
  83. engine.resume(options.state);
  84. } else {
  85. engine.init();
  86. engine.on('destroyed', ({ id, status }) => {
  87. if (status === 'stopped') return;
  88. browser.permissions
  89. .contains({ permissions: ['notifications'] })
  90. .then((hasPermission) => {
  91. if (!hasPermission || !workflowData.settings.notification) return;
  92. const name = workflowData.name.slice(0, 32);
  93. browser.notifications.create(`logs:${id}`, {
  94. type: 'basic',
  95. iconUrl: browser.runtime.getURL('icon-128.png'),
  96. title: status === 'success' ? 'Success' : 'Error',
  97. message: `${
  98. status === 'success' ? 'Successfully' : 'Failed'
  99. } to run the "${name}" workflow`,
  100. });
  101. });
  102. });
  103. const lastCheckStatus = localStorage.getItem('check-status');
  104. const isSameDay = dayjs().isSame(lastCheckStatus, 'day');
  105. if (!isSameDay) {
  106. fetchApi('/status')
  107. .then((response) => response.json())
  108. .then(() => {
  109. localStorage.setItem('check-status', new Date());
  110. });
  111. }
  112. }
  113. return engine;
  114. },
  115. };
  116. async function updateRecording(callback) {
  117. const { isRecording, recording } = await browser.storage.local.get([
  118. 'isRecording',
  119. 'recording',
  120. ]);
  121. if (!isRecording || !recording) return;
  122. callback(recording);
  123. await browser.storage.local.set({ recording });
  124. }
  125. async function openDashboard(url) {
  126. const tabOptions = {
  127. active: true,
  128. url: browser.runtime.getURL(
  129. `/newtab.html#${typeof url === 'string' ? url : ''}`
  130. ),
  131. };
  132. try {
  133. const [tab] = await browser.tabs.query({
  134. url: browser.runtime.getURL('/newtab.html'),
  135. });
  136. if (tab) {
  137. await browser.tabs.update(tab.id, tabOptions);
  138. if (tabOptions.url.includes('workflows/')) {
  139. await browser.tabs.reload(tab.id);
  140. }
  141. } else {
  142. browser.tabs.create(tabOptions);
  143. }
  144. } catch (error) {
  145. console.error(error);
  146. }
  147. }
  148. async function checkVisitWebTriggers(tabId, tabUrl) {
  149. const workflowState = await workflow.states.get(({ state }) =>
  150. state.tabIds.includes(tabId)
  151. );
  152. const visitWebTriggers = await browserStorage.get('visitWebTriggers');
  153. const triggeredWorkflow = visitWebTriggers?.find(({ url, isRegex, id }) => {
  154. if (url.trim() === '') return false;
  155. const matchUrl = tabUrl.match(isRegex ? new RegExp(url, 'g') : url);
  156. return matchUrl && id !== workflowState?.workflowId;
  157. });
  158. if (triggeredWorkflow) {
  159. const workflowData = await workflow.get(triggeredWorkflow.id);
  160. if (workflowData) workflow.execute(workflowData, { tabId });
  161. }
  162. }
  163. async function checkRecordingWorkflow(tabId, tabUrl) {
  164. if (!validateUrl(tabUrl)) return;
  165. const isRecording = await browserStorage.get('isRecording');
  166. if (!isRecording) return;
  167. await browser.tabs.executeScript(tabId, {
  168. allFrames: true,
  169. file: 'recordWorkflow.bundle.js',
  170. });
  171. }
  172. browser.webNavigation.onCompleted.addListener(
  173. async ({ tabId, url, frameId }) => {
  174. if (frameId > 0) return;
  175. checkRecordingWorkflow(tabId, url);
  176. checkVisitWebTriggers(tabId, url);
  177. }
  178. );
  179. browser.commands.onCommand.addListener((name) => {
  180. if (name === 'open-dashboard') openDashboard();
  181. });
  182. browser.webNavigation.onCommitted.addListener(
  183. ({ frameId, tabId, url, transitionType }) => {
  184. const allowedType = ['link', 'typed'];
  185. if (frameId !== 0 || !allowedType.includes(transitionType)) return;
  186. updateRecording((recording) => {
  187. if (tabId !== recording.activeTab.id) return;
  188. const lastFlow = recording.flows.at(-1) ?? {};
  189. const isInvalidNewtabFlow =
  190. lastFlow &&
  191. lastFlow.id === 'new-tab' &&
  192. !validateUrl(lastFlow.data.url);
  193. if (isInvalidNewtabFlow) {
  194. lastFlow.data.url = url;
  195. lastFlow.description = url;
  196. } else if (validateUrl(url)) {
  197. if (lastFlow?.id !== 'link' || !lastFlow.isClickLink) {
  198. recording.flows.push({
  199. id: 'new-tab',
  200. description: url,
  201. data: {
  202. url,
  203. updatePrevTab: recording.activeTab.id === tabId,
  204. },
  205. });
  206. }
  207. recording.activeTab.id = tabId;
  208. recording.activeTab.url = url;
  209. }
  210. });
  211. }
  212. );
  213. browser.tabs.onActivated.addListener(async ({ tabId }) => {
  214. const { url, id, title } = await browser.tabs.get(tabId);
  215. if (!validateUrl(url)) return;
  216. updateRecording((recording) => {
  217. recording.activeTab = { id, url };
  218. recording.flows.push({
  219. id: 'switch-tab',
  220. description: title,
  221. data: {
  222. url,
  223. matchPattern: url,
  224. createIfNoMatch: true,
  225. },
  226. });
  227. });
  228. });
  229. browser.tabs.onCreated.addListener(async (tab) => {
  230. const { isRecording, recording } = await browser.storage.local.get([
  231. 'isRecording',
  232. 'recording',
  233. ]);
  234. if (!isRecording || !recording) return;
  235. const url = tab.url || tab.pendingUrl;
  236. const lastFlow = recording.flows[recording.flows.length - 1];
  237. const invalidPrevFlow =
  238. lastFlow && lastFlow.id === 'new-tab' && !validateUrl(lastFlow.data.url);
  239. if (!invalidPrevFlow) {
  240. const validUrl = validateUrl(url) ? url : '';
  241. recording.flows.push({
  242. id: 'new-tab',
  243. data: {
  244. url: validUrl,
  245. description: tab.title || validUrl,
  246. },
  247. });
  248. }
  249. recording.activeTab = {
  250. url,
  251. id: tab.id,
  252. };
  253. await browser.storage.local.set({ recording });
  254. });
  255. browser.alarms.onAlarm.addListener(async ({ name }) => {
  256. const currentWorkflow = await workflow.get(name);
  257. if (!currentWorkflow) return;
  258. const drawflow =
  259. typeof currentWorkflow.drawflow === 'string'
  260. ? parseJSON(currentWorkflow.drawflow, {})
  261. : currentWorkflow.drawflow;
  262. const { data } = findTriggerBlock(drawflow) || {};
  263. if (data && data.type === 'interval' && data.fixedDelay) {
  264. const workflowState = await workflow.states.get(
  265. ({ workflowId }) => name === workflowId
  266. );
  267. if (workflowState) {
  268. let { workflowQueue } = await browser.storage.local.get('workflowQueue');
  269. workflowQueue = workflowQueue || [];
  270. if (!workflowQueue.includes(name)) {
  271. (workflowQueue = workflowQueue || []).push(name);
  272. await browser.storage.local.set({ workflowQueue });
  273. }
  274. return;
  275. }
  276. }
  277. workflow.execute(currentWorkflow);
  278. if (data && data.type === 'specific-day') {
  279. registerSpecificDay(currentWorkflow.id, data);
  280. }
  281. });
  282. const contextMenu =
  283. BROWSER_TYPE === 'firefox' ? browser.menus : browser.contextMenus;
  284. if (contextMenu && contextMenu.onClicked) {
  285. contextMenu.onClicked.addListener(
  286. async ({ parentMenuItemId, menuItemId }, tab) => {
  287. try {
  288. if (parentMenuItemId !== 'automaContextMenu') return;
  289. const message = await browser.tabs.sendMessage(tab.id, {
  290. frameId: 0,
  291. type: 'context-element',
  292. });
  293. const workflowData = await workflow.get(menuItemId);
  294. workflow.execute(workflowData, {
  295. data: {
  296. variables: message,
  297. },
  298. });
  299. } catch (error) {
  300. console.error(error);
  301. }
  302. }
  303. );
  304. }
  305. if (browser.notifications && browser.notifications.onClicked) {
  306. browser.notifications.onClicked.addListener((notificationId) => {
  307. if (notificationId.startsWith('logs')) {
  308. const { 1: logId } = notificationId.split(':');
  309. openDashboard(`/logs/${logId}`);
  310. }
  311. });
  312. }
  313. browser.runtime.onInstalled.addListener(async ({ reason }) => {
  314. try {
  315. if (reason === 'install') {
  316. await browser.storage.local.set({
  317. logs: [],
  318. shortcuts: {},
  319. workflows: [],
  320. collections: [],
  321. workflowState: {},
  322. isFirstTime: true,
  323. visitWebTriggers: [],
  324. });
  325. await browser.tabs.create({
  326. active: true,
  327. url: browser.runtime.getURL('newtab.html#/welcome'),
  328. });
  329. return;
  330. }
  331. if (reason === 'update') {
  332. let { workflows } = await browser.storage.local.get('workflows');
  333. const alarmTypes = ['specific-day', 'date', 'interval'];
  334. workflows = Array.isArray(workflows)
  335. ? workflows
  336. : Object.values(workflows);
  337. workflows.forEach(({ trigger, drawflow, id }) => {
  338. let workflowTrigger = trigger?.data || trigger;
  339. if (!trigger) {
  340. const flows = parseJSON(drawflow, drawflow);
  341. workflowTrigger = findTriggerBlock(flows)?.data;
  342. }
  343. const triggerType = workflowTrigger?.type;
  344. if (alarmTypes.includes(triggerType)) {
  345. registerWorkflowTrigger(id, { data: workflowTrigger });
  346. } else if (triggerType === 'context-menu') {
  347. registerContextMenu(id, workflowTrigger);
  348. }
  349. });
  350. }
  351. } catch (error) {
  352. console.error(error);
  353. }
  354. });
  355. browser.runtime.onStartup.addListener(async () => {
  356. const { workflows } = await browser.storage.local.get('workflows');
  357. for (const currWorkflow of workflows) {
  358. let triggerBlock = currWorkflow.trigger;
  359. if (!triggerBlock) {
  360. const flow =
  361. typeof currWorkflow.drawflow === 'string'
  362. ? parseJSON(currWorkflow.drawflow, {})
  363. : currWorkflow.drawflow;
  364. triggerBlock = findTriggerBlock(flow)?.data;
  365. }
  366. if (triggerBlock) {
  367. if (triggerBlock.type === 'specific-day') {
  368. const alarm = await browser.alarms.get(currWorkflow.id);
  369. if (!alarm) await registerSpecificDay(currWorkflow.id, triggerBlock);
  370. } else if (triggerBlock.type === 'date' && triggerBlock.date) {
  371. const [hour, minute] = triggerBlock.time.split(':');
  372. const date = dayjs(triggerBlock.date)
  373. .hour(hour)
  374. .minute(minute)
  375. .second(0);
  376. const isBefore = dayjs().isBefore(date);
  377. if (isBefore) {
  378. await browser.alarms.create(currWorkflow.id, {
  379. when: date.valueOf(),
  380. });
  381. }
  382. } else if (triggerBlock.type === 'on-startup') {
  383. workflow.execute(currWorkflow);
  384. }
  385. }
  386. }
  387. });
  388. const message = new MessageListener('background');
  389. message.on('fetch:text', (url) => {
  390. return fetch(url).then((response) => response.text());
  391. });
  392. message.on('open:dashboard', async (url) => {
  393. await openDashboard(url);
  394. return Promise.resolve(true);
  395. });
  396. message.on('set:active-tab', (tabId) => {
  397. return browser.tabs.update(tabId, { active: true });
  398. });
  399. message.on('debugger:send-command', ({ tabId, method, params }) => {
  400. return new Promise((resolve) => {
  401. chrome.debugger.sendCommand({ tabId }, method, params, resolve);
  402. });
  403. });
  404. message.on('debugger:type', ({ tabId, commands, delay }) => {
  405. return new Promise((resolve) => {
  406. let index = 0;
  407. async function executeCommands() {
  408. const command = commands[index];
  409. if (!command) {
  410. resolve();
  411. return;
  412. }
  413. chrome.debugger.sendCommand(
  414. { tabId },
  415. 'Input.dispatchKeyEvent',
  416. command,
  417. async () => {
  418. if (delay > 0) await sleep(delay);
  419. index += 1;
  420. executeCommands();
  421. }
  422. );
  423. }
  424. executeCommands();
  425. });
  426. });
  427. message.on('get:sender', (_, sender) => sender);
  428. message.on('get:file', (path) => getFile(path));
  429. message.on('get:tab-screenshot', (options) =>
  430. browser.tabs.captureVisibleTab(options)
  431. );
  432. message.on('collection:execute', (collection) => {
  433. const engine = new CollectionEngine(collection, {
  434. states: workflow.states,
  435. logger: workflow.logger,
  436. });
  437. engine.init();
  438. });
  439. message.on('workflow:execute', (workflowData, sender) => {
  440. if (workflowData.includeTabId) {
  441. if (!workflowData.options) workflowData.options = {};
  442. workflowData.options.tabId = sender.tab.id;
  443. }
  444. workflow.execute(workflowData, workflowData?.options || {});
  445. });
  446. message.on('workflow:stop', (id) => workflow.states.stop(id));
  447. browser.runtime.onMessage.addListener(message.listener());