diff --git a/.eslintrc.js b/.eslintrc.js index b3bf2d8..9f4ca73 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -37,6 +37,7 @@ module.exports = { }, plugins: ['react', '@typescript-eslint', 'typescript-sort-keys', 'sort-keys-fix'], rules: { + 'no-await-in-loop': 'off', 'prettier/prettier': 'error', 'sort-keys-fix/sort-keys-fix': 'error', 'testing-library/no-debug': 'off', diff --git a/src/background.ts b/src/background.ts index b441701..6d43701 100644 --- a/src/background.ts +++ b/src/background.ts @@ -6,18 +6,27 @@ browser.alarms.onAlarm.addListener(async (alarm) => { // |alarm| can be undefined because onAlarm also gets called from // window.setTimeout on old chrome versions. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (alarm?.name == 'heartbeatAlarm') { - console.log('recording a heartbeat - alarm triggered'); - - await WakaTimeCore.recordHeartbeat(); + if (alarm && alarm.name == 'heartbeatAlarm') { + // Checks if the user is online and if there are cached heartbeats requests, + // if so then procedd to send these payload to wakatime api + if (navigator.onLine) { + const { cachedHeartbeats } = await browser.storage.sync.get({ + cachedHeartbeats: [], + }); + await browser.storage.sync.set({ cachedHeartbeats: [] }); + await WakaTimeCore.sendCachedHeartbeatsRequest(cachedHeartbeats as Record[]); + } } }); +// Create a new alarm for sending cached heartbeats. +browser.alarms.create('heartbeatAlarm', { periodInMinutes: 2 }); + /** * Whenever a active tab is changed it records a heartbeat with that tab url. */ browser.tabs.onActivated.addListener(async () => { - console.log('recording a heartbeat - active tab changed'); + console.log('recording a heartbeat - active tab changed '); await WakaTimeCore.recordHeartbeat(); }); @@ -28,8 +37,6 @@ browser.windows.onFocusChanged.addListener(async (windowId) => { if (windowId != browser.windows.WINDOW_ID_NONE) { console.log('recording a heartbeat - active window changed'); await WakaTimeCore.recordHeartbeat(); - } else { - console.log('lost focus'); } }); diff --git a/src/components/NavBar.test.tsx b/src/components/NavBar.test.tsx new file mode 100644 index 0000000..0bb4348 --- /dev/null +++ b/src/components/NavBar.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { renderWithProviders } from '../utils/test-utils'; +import NavBar from './NavBar'; + +jest.mock('webextension-polyfill', () => { + return { + runtime: { + getManifest: () => { + return { version: 'test-version' }; + }, + }, + }; +}); + +describe('NavBar', () => { + it('should render properly', () => { + const { container } = renderWithProviders(); + expect(container).toMatchInlineSnapshot(` +
+ +
+ `); + }); +}); diff --git a/src/core/WakaTimeCore.ts b/src/core/WakaTimeCore.ts index 9f7923f..3a828a6 100644 --- a/src/core/WakaTimeCore.ts +++ b/src/core/WakaTimeCore.ts @@ -1,9 +1,13 @@ import axios, { AxiosResponse } from 'axios'; import moment from 'moment'; -import { Tabs } from 'webextension-polyfill'; +import browser, { Tabs } from 'webextension-polyfill'; import { AxiosUserResponse, User } from '../types/user'; import config from '../config/config'; import { SummariesPayload, GrandTotal } from '../types/summaries'; +import changeExtensionState from '../utils/changeExtensionState'; +import contains from '../utils/contains'; +import { SendHeartbeat } from '../types/heartbeats'; +import getDomainFromUrl from '../utils/getDomainFromUrl'; class WakaTimeCore { tabsWithDevtoolsOpen: Tabs.Tab[]; @@ -30,6 +34,11 @@ class WakaTimeCore { return summariesAxiosPayload.data.data[0].grand_total; } + /** + * Checks if the user is logged in. + * + * @returns {*} + */ async checkAuth(api_key = ''): Promise { const userPayload: AxiosResponse = await axios.get( config.currentUserApiUrl, @@ -38,13 +47,258 @@ class WakaTimeCore { return userPayload.data.data; } + async getApiKey(): Promise { + const storage = await browser.storage.sync.get({ + apiKey: config.apiKey, + }); + const apiKey = storage.apiKey as string; + return apiKey; + } + + /** + * Depending on various factors detects the current active tab URL or domain, + * and sends it to WakaTime for logging. + */ async recordHeartbeat(): Promise { + const apiKey = await this.getApiKey(); + if (!apiKey) { + return changeExtensionState('notLogging'); + } const items = await browser.storage.sync.get({ blacklist: '', loggingEnabled: config.loggingEnabled, loggingStyle: config.loggingStyle, whitelist: '', }); + if (items.loggingEnabled === true) { + await changeExtensionState('allGood'); + + const newState = await browser.idle.queryState(config.detectionIntervalInSeconds); + + if (newState === 'active') { + // Get current tab URL. + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (tabs.length == 0) return; + + const currentActiveTab = tabs[0]; + + if (items.loggingStyle == 'blacklist') { + if (!contains(currentActiveTab.url as string, items.blacklist as string)) { + await this.sendHeartbeat( + { + project: null, + url: currentActiveTab.url as string, + }, + apiKey, + ); + } else { + await changeExtensionState('blacklisted'); + console.log(`${currentActiveTab.url} is on a blacklist.`); + } + } + + if (items.loggingStyle == 'whitelist') { + const heartbeat = this.getHeartbeat( + currentActiveTab.url as string, + items.whitelist as string, + ); + if (heartbeat.url) { + await this.sendHeartbeat(heartbeat, apiKey); + } else { + await changeExtensionState('whitelisted'); + console.log(`${currentActiveTab.url} is not on a whitelist.`); + } + } + } + } else { + await changeExtensionState('notLogging'); + } + } + + /** + * Creates an array from list using \n as delimiter + * and checks if any element in list is contained in the url. + * Also checks if element is assigned to a project using @@ as delimiter + * + * @param url + * @param list + * @returns {object} + */ + getHeartbeat(url: string, list: string) { + const projectIndicatorCharacters = '@@'; + + const lines = list.split('\n'); + for (let i = 0; i < lines.length; i++) { + // strip (http:// or https://) and trailing (`/` or `@@`) + const cleanLine = lines[i] + .trim() + .replace(/(\/|@@)$/, '') + .replace(/^(?:https?:\/\/)?/i, ''); + if (cleanLine === '') continue; + + const projectIndicatorIndex = cleanLine.lastIndexOf(projectIndicatorCharacters); + const projectIndicatorExists = projectIndicatorIndex > -1; + let projectName = null; + let urlFromLine = cleanLine; + if (projectIndicatorExists) { + const start = projectIndicatorIndex + projectIndicatorCharacters.length; + projectName = cleanLine.substring(start); + urlFromLine = cleanLine + .replace(cleanLine.substring(projectIndicatorIndex), '') + .replace(/\/$/, ''); + } + const schemaHttpExists = url.match(/^http:\/\//i); + const schemaHttpsExists = url.match(/^https:\/\//i); + let schema = ''; + if (schemaHttpExists) { + schema = 'http://'; + } + if (schemaHttpsExists) { + schema = 'https://'; + } + const cleanUrl = url + .trim() + .replace(/(\/|@@)$/, '') + .replace(/^(?:https?:\/\/)?/i, ''); + const startsWithUrl = cleanUrl.toLowerCase().includes(urlFromLine.toLowerCase()); + if (startsWithUrl) { + return { + project: projectName, + url: schema + urlFromLine, + }; + } + } + + return { + project: null, + url: null, + }; + } + + /** + * Given the heartbeat and logging type it creates a payload and + * sends an ajax post request to the API. + * + * @param heartbeat + * @param debug + */ + async sendHeartbeat(heartbeat: SendHeartbeat, apiKey: string): Promise { + let payload; + + const loggingType = await this.getLoggingType(); + // Get only the domain from the entity. + // And send that in heartbeat + if (loggingType == 'domain') { + heartbeat.url = getDomainFromUrl(heartbeat.url); + payload = this.preparePayload(heartbeat, 'domain'); + await this.sendPostRequestToApi(payload, apiKey); + } + // Send entity in heartbeat + else if (loggingType == 'url') { + payload = this.preparePayload(heartbeat, 'url'); + await this.sendPostRequestToApi(payload, apiKey); + } + } + + /** + * Returns a promise with logging type variable. + * + * @returns {*} + * @private + */ + async getLoggingType(): Promise { + const items = await browser.storage.sync.get({ + loggingType: config.loggingType, + }); + + return items.loggingType; + } + + /** + * Creates payload for the heartbeat and returns it as JSON. + * + * @param heartbeat + * @param type + * @param debug + * @returns {*} + * @private + */ + preparePayload(heartbeat: SendHeartbeat, type: string): Record { + let browserName = 'chrome'; + let userAgent; + if (navigator.userAgent.includes('Firefox')) { + browserName = 'firefox'; + userAgent = navigator.userAgent.match(/Firefox\/\S+/g)![0]; + } else { + userAgent = navigator.userAgent.match(/Chrome\/\S+/g)![0]; + } + const payload: Record = { + entity: heartbeat.url, + time: moment().format('X'), + type: type, + user_agent: `${userAgent} ${browserName}-wakatime/${config.version}`, + }; + + if (heartbeat.project) { + payload.project = heartbeat.project; + } + + return payload; + } + + /** + * Sends AJAX request with payload to the heartbeat API as JSON. + * + * @param payload + * @param method + * @returns {*} + */ + async sendPostRequestToApi(payload: Record, apiKey = '') { + try { + const response = await fetch(`${config.heartbeatApiUrl}?api_key=${apiKey}`, { + body: JSON.stringify(payload), + method: 'POST', + }); + const data = await response.json(); + return data; + } catch (err: unknown) { + // Stores the payload of the request to be send later + const { cachedHeartbeats } = await browser.storage.sync.get({ + cachedHeartbeats: [], + }); + cachedHeartbeats.push(payload); + await browser.storage.sync.set({ cachedHeartbeats }); + await changeExtensionState('notSignedIn'); + } + } + + /** + * Sends cached heartbeats request to wakatime api + * @param requests + */ + async sendCachedHeartbeatsRequest(requests: Record[]): Promise { + const apiKey = await this.getApiKey(); + if (!apiKey) { + return changeExtensionState('notLogging'); + } + const chunkSize = 50; // Create batches of max 50 request + for (let i = 0; i < requests.length; i += chunkSize) { + const chunk = requests.slice(i, i + chunkSize); + const requestsPromises: Promise[] = []; + chunk.forEach((request) => + requestsPromises.push( + fetch(`${config.heartbeatApiUrl}?api_key=${apiKey}`, { + body: JSON.stringify(request), + method: 'POST', + }), + ), + ); + try { + await Promise.all(requestsPromises); + } catch (error: unknown) { + console.log('Error sending heartbeats'); + } + } } } diff --git a/src/manifests/firefox.json b/src/manifests/firefox.json index bb52660..96f77db 100644 --- a/src/manifests/firefox.json +++ b/src/manifests/firefox.json @@ -39,5 +39,5 @@ "storage", "idle" ], - "version": "2.0.1" + "version": "3.0.0" } diff --git a/src/types/heartbeats.ts b/src/types/heartbeats.ts index e178266..8067433 100644 --- a/src/types/heartbeats.ts +++ b/src/types/heartbeats.ts @@ -26,3 +26,8 @@ export interface Datum { user_agent_id: string; user_id: string; } + +export interface SendHeartbeat { + project: string | null; + url: string; +} diff --git a/src/utils/changeExtensionIcon.ts b/src/utils/changeExtensionIcon.ts index a5a426f..650634b 100644 --- a/src/utils/changeExtensionIcon.ts +++ b/src/utils/changeExtensionIcon.ts @@ -7,22 +7,23 @@ type ColorIconTypes = 'gray' | 'red' | 'white' | ''; * It changes the extension icon color. */ export default async function changeExtensionIcon(color?: ColorIconTypes): Promise { + let path; if (color) { - const path = `./graphics/wakatime-logo-38-${color}.png`; - - await browser.browserAction.setIcon({ - path: path, - }); + path = `./graphics/wakatime-logo-38-${color}.png`; } else { const { theme } = await browser.storage.sync.get({ theme: config.theme, }); - const path = + path = theme === config.theme ? './graphics/wakatime-logo-38.png' : './graphics/wakatime-logo-38-white.png'; - await browser.browserAction.setIcon({ - path: path, - }); + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (browser.browserAction) { + await browser.browserAction.setIcon({ path: path }); // Support for FF with manifest V2 + } else { + await browser.action.setIcon({ path: path }); // Support for Chrome with manifest V3 } } diff --git a/src/utils/changeExtensionTooltip.ts b/src/utils/changeExtensionTooltip.ts index ef4e4ba..d6cc4ae 100644 --- a/src/utils/changeExtensionTooltip.ts +++ b/src/utils/changeExtensionTooltip.ts @@ -12,5 +12,10 @@ export default async function changeExtensionTooltip(text: string): Promise(needle: T, haystack: T[]): boolean { +export default function inArray(needle: T, haystack: T[]): boolean { for (let i = 0; i < haystack.length; i++) { if (needle == haystack[i]) { return true;