index.js 14 KB

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