Files
omnivore/pkg/extension/src/scripts/background.js
2023-03-20 21:21:39 +08:00

825 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* global
ACTIONS
CREATE_ARTICLE_QUERY
CREATE_ARTICLE_SAVING_REQUEST_QUERY
ENV_IS_FIREFOX
ENV_IS_EDGE
browserApi
browserActionApi
browserScriptingApi
fetch
XMLHttpRequest
*/
'use strict'
import { v4 as uuidv4 } from 'uuid'
let authToken = undefined
const omnivoreURL = process.env.OMNIVORE_URL
const omnivoreGraphqlURL = process.env.OMNIVORE_GRAPHQL_URL
function getCurrentTab() {
return new Promise((resolve) => {
browserApi.tabs.query(
{
active: true,
currentWindow: true,
},
function (tabs) {
resolve(tabs[0] || null)
}
)
})
}
function setupConnection(xhr) {
xhr.setRequestHeader('Content-Type', 'application/json')
if (authToken) {
xhr.setRequestHeader('Authorization', authToken)
}
}
/* other code */
function uploadFile({ id, uploadSignedUrl }, contentType, contentObjUrl) {
return fetch(contentObjUrl)
.then((r) => r.blob())
.then((blob) => {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', uploadSignedUrl, true)
xhr.setRequestHeader('Content-Type', contentType)
xhr.onerror = () => {
resolve(undefined)
}
xhr.onload = () => {
// Uploaded.
resolve({ id })
}
xhr.send(blob)
})
})
.catch((err) => {
console.error('error uploading file', err)
return undefined
})
}
function savePdfFile(tab, url, contentType, contentObjUrl) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.onreadystatechange = async function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
const { data } = JSON.parse(xhr.response)
if ('errorCodes' in data.uploadFileRequest) {
if (data.uploadFileRequest.errorCodes[0] === 'UNAUTHORIZED') {
clearClickCompleteState()
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.ShowMessage,
payload: {
text: 'Unable to save page',
type: 'error',
errorCode: 401,
url: omnivoreURL,
},
})
}
}
if (
!data.uploadFileRequest ||
!data.uploadFileRequest.id ||
!data.uploadFileRequest.createdPageId ||
'errorCodes' in data.uploadFileRequest
) {
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.ShowMessage,
payload: {
text: 'Unable to save page',
type: 'error',
},
})
} else {
const result = await uploadFile(
data.uploadFileRequest,
contentType,
contentObjUrl
)
URL.revokeObjectURL(contentObjUrl)
if (!result) {
return undefined
}
const createdPageId = data.uploadFileRequest.createdPageId
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.UpdateStatus,
payload: {
status: 'success',
target: 'page',
requestId: createdPageId,
},
})
return resolve(data.uploadFileRequest)
}
} else if (xhr.status === 400) {
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.ShowMessage,
payload: {
text: 'Unable to save page',
type: 'error',
},
})
}
resolve(false)
}
}
const data = JSON.stringify({
query: `mutation UploadFileRequest($input: UploadFileRequestInput!) {
uploadFileRequest(input:$input) {
... on UploadFileRequestError {
errorCodes
}
... on UploadFileRequestSuccess {
id
createdPageId
uploadSignedUrl
}
}
}`,
variables: {
input: {
url,
contentType,
createPageEntry: true,
},
},
})
xhr.open('POST', omnivoreGraphqlURL + 'graphql', true)
setupConnection(xhr)
xhr.send(data)
})
}
function clearClickCompleteState() {
getStorageItem('postInstallClickComplete').then(
(postInstallClickComplete) => {
if (postInstallClickComplete) {
removeStorage('postInstallClickComplete')
}
}
)
}
function handleSaveResponse(tab, field, xhr) {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
const { data } = JSON.parse(xhr.response)
console.log('response data: ', data)
const item = data[field]
if (!item) {
return undefined
}
if ('errorCodes' in item) {
const messagePayload = {
text:
descriptions[data.createArticle.errorCodes[0]] ||
'Unable to save page',
type: 'error',
}
if (item.errorCodes[0] === 'UNAUTHORIZED') {
messagePayload.errorCode = 401
messagePayload.url = omnivoreURL
clearClickCompleteState()
}
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.ShowMessage,
payload: messagePayload,
})
return undefined
}
const url = item['url']
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.UpdateStatus,
payload: {
status: 'success',
target: 'page',
link: url ?? omnivoreURL + '/home',
},
})
return item
} else if (xhr.status === 400) {
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.ShowMessage,
payload: {
text: 'Unable to save page',
type: 'error',
},
})
return undefined
}
}
}
async function saveUrl(currentTab, url) {
const requestId = uuidv4()
await saveApiRequest(currentTab, SAVE_URL_QUERY, 'saveUrl', {
source: 'extension',
clientRequestId: requestId,
url: encodeURI(url),
})
}
async function saveApiRequest(currentTab, query, field, input) {
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.ShowToolbar,
payload: {
type: 'loading',
requestId: input.clientRequestId,
},
})
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', omnivoreGraphqlURL + 'graphql', true)
setupConnection(xhr)
xhr.onerror = (err) => {
reject(err)
}
xhr.onload = () => {
try {
const res = handleSaveResponse(currentTab, field, xhr)
if (!res) {
return reject()
}
resolve(res)
} catch (err) {
reject(err)
}
}
const data = JSON.stringify({
query,
variables: {
input,
},
})
xhr.send(data)
}).catch((err) => {
console.log('error saving page', err)
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.ShowMessage,
payload: {
text: 'Unable to save page',
type: 'error',
},
})
return undefined
})
}
async function saveArticle(tab) {
browserApi.tabs.sendMessage(
tab.id,
{
action: ACTIONS.GetContent,
},
async (response) => {
if (!response || typeof response !== 'object') {
// In the case of an invalid response, we attempt
// to just save the URL. This can happen in Firefox
// with PDF URLs
await saveUrl(tab, tab.url)
return
}
const requestId = uuidv4()
var { type } = response
const { pageInfo, doc, uploadContentObjUrl } = response
if (type == 'html' && handleBackendUrl(tab.url)) {
type = 'url'
}
switch (type) {
case 'html': {
await saveApiRequest(tab, SAVE_PAGE_QUERY, 'savePage', {
source: 'extension',
clientRequestId: requestId,
originalContent: doc,
title: pageInfo.title,
url: encodeURI(tab.url),
})
break
}
case 'url': {
await saveApiRequest(tab, SAVE_URL_QUERY, 'saveUrl', {
source: 'extension',
clientRequestId: requestId,
url: encodeURI(tab.url),
})
break
}
case 'pdf': {
const uploadResult = await savePdfFile(
tab,
encodeURI(tab.url),
pageInfo.contentType,
uploadContentObjUrl
)
if (!uploadResult || !uploadResult.id) {
// If the upload failed for any reason, try to save the PDF URL instead
await saveApiRequest(tab, SAVE_URL_QUERY, 'saveUrl', {
source: 'extension',
clientRequestId: requestId,
url: encodeURI(tab.url),
})
return
}
break
}
}
}
)
}
// credit: https://stackoverflow.com/questions/21535233/injecting-multiple-scripts-through-executescript-in-google-chrome
function executeScripts(tabId, scriptsArray, onCompleteCallback) {
function createCallback(tabId, injectDetails, innerCallback) {
return function () {
browserScriptingApi.executeScript(tabId, injectDetails, innerCallback)
}
}
// any callback passed by caller is called upon all scripts execute completion
let callback = onCompleteCallback
for (let i = scriptsArray.length - 1; i >= 0; --i) {
callback = createCallback(tabId, { file: scriptsArray[i] }, callback)
}
if (callback !== null) {
callback() // execute outermost function
}
}
function cleanupTabState(tabId) {
getStorage().then(function (result) {
const itemsToRemove = []
const keys = Object.keys(result)
const keyPrefix = tabId + '_saveInProgress'
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (key.startsWith(keyPrefix)) {
itemsToRemove.push(key)
}
}
if (itemsToRemove.length === 0) return
removeStorage(itemsToRemove)
})
}
/* setup an interval timer and a timeout timer (failsave) to clear interval timer after a timeout */
function setupTimedInterval(
timerCallback,
timeoutCallback,
callback,
delay = 1000,
timeout = 10500
) {
const intervalId = setInterval(timerCallback, delay)
const timeoutId = setTimeout(() => {
clearInterval(intervalId)
timeoutCallback()
}, timeout)
if (callback && typeof callback === 'function') {
callback(intervalId, timeoutId)
}
}
async function clearPreviousIntervalTimer(tabId) {
const prevIntervalId = await getStorageItem(tabId + '_saveInProgress')
if (!prevIntervalId) return
clearInterval(prevIntervalId)
const intervalTimeoutId = await getStorageItem(
tabId + '_saveInProgressTimeoutId_' + prevIntervalId
)
if (!intervalTimeoutId) return
clearTimeout(intervalTimeoutId)
}
function onExtensionClick(tabId) {
/* clear any previous timers on each click */
clearPreviousIntervalTimer(tabId)
/* Method to check tab loading state prior to save */
function checkTabLoadingState(onSuccess, onPending) {
browserApi.tabs.get(tabId, async (tab) => {
if (tab.status !== 'complete') {
// show message to user on page yet to complete load
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.ShowMessage,
payload: {
type: 'loading',
text: 'Page loading...',
},
})
if (onPending && typeof onPending === 'function') {
onPending()
}
} else {
if (onSuccess && typeof onSuccess === 'function') {
onSuccess()
}
await saveArticle(tab)
try {
await updateLabelsCache(omnivoreGraphqlURL + 'graphql', tab)
browserApi.tabs.sendMessage(tab.id, {
action: ACTIONS.LabelCacheUpdated,
payload: {},
})
} catch (err) {
console.error('error fetching labels', err, omnivoreGraphqlURL)
return undefined
}
}
})
}
/* call above method immediately, and if tab in loading state then setup timer */
checkTabLoadingState(null, () => {
setupTimedInterval(
() => {
/* interval timer to check tab/page loading state and save */
checkTabLoadingState(() => {
clearPreviousIntervalTimer(tabId)
})
},
() => {
/* timeout handling, clear timer and show timeout msg */
clearPreviousIntervalTimer(tabId)
browserApi.tabs.get(tabId, async (tab) => {
/*
* post timeout, we proceed to save as some sites (people.com) take a
* long time to reach complete state and remain in interactive state.
*/
await saveArticle(tab)
})
},
(intervalId, timeoutId) => {
/* Track interval timer and timeout timer in browser storage keyed by tabId */
const itemsToSet = {}
itemsToSet[tabId + '_saveInProgress'] = intervalId
itemsToSet[tabId + '_saveInProgressTimeoutId_' + intervalId] = timeoutId
setStorage(itemsToSet)
}
)
})
}
/* After installing extension, if user hasnt logged into Omnivore, then we show the splash popup */
function checkAuthOnFirstClickPostInstall(tabId) {
return getStorageItem('postInstallClickComplete').then(
async (postInstallClickComplete) => {
if (postInstallClickComplete) return true
if (
typeof browser !== 'undefined' &&
browser.runtime &&
browser.runtime.sendNativeMessage
) {
const response = await browser.runtime.sendNativeMessage('omnivore', {
message: ACTIONS.GetAuthToken,
})
if (response.authToken) {
authToken = response.authToken
}
}
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
const { data } = JSON.parse(xhr.response)
if (!data.me) {
browserApi.tabs.sendMessage(tabId, {
action: ACTIONS.ShowMessage,
payload: {
type: 'loading',
text: 'Loading...',
},
})
browserApi.tabs.sendMessage(tabId, {
action: ACTIONS.ShowMessage,
payload: {
text: '',
type: 'error',
errorCode: 401,
url: omnivoreURL,
},
})
resolve(null)
} else {
setStorage({
postInstallClickComplete: true,
})
resolve(true)
}
}
}
const query = '{me{id}}'
const data = JSON.stringify({
query,
})
xhr.open('POST', omnivoreGraphqlURL + 'graphql', true)
setupConnection(xhr)
xhr.send(data)
})
}
)
}
function handleActionClick() {
executeAction(function (currentTab) {
onExtensionClick(currentTab.id)
})
}
function executeAction(action) {
getCurrentTab().then((currentTab) => {
browserApi.tabs.sendMessage(
currentTab.id,
{
action: ACTIONS.Ping,
},
async function (response) {
if (response && response.pong) {
// Content script ready
const isSignedUp = await checkAuthOnFirstClickPostInstall(
currentTab.id
)
if (isSignedUp) {
action(currentTab)
}
} else {
const extensionManifest = browserApi.runtime.getManifest()
const contentScripts = extensionManifest.content_scripts
// No listener on the other end, inject content scripts
const scriptFiles = [...contentScripts[0].js, ...contentScripts[1].js]
executeScripts(currentTab.id, scriptFiles, async function () {
const isSignedUp = await checkAuthOnFirstClickPostInstall(
currentTab.id
)
if (isSignedUp) {
action(currentTab)
}
})
}
}
)
})
}
function getIconPath(active, dark) {
let iconPath = '/images/toolbar/icon'
if (ENV_IS_FIREFOX) {
iconPath += '_firefox'
} else if (ENV_IS_EDGE) {
iconPath += '_edge'
}
if (!active) {
iconPath += '_inactive'
}
/* we have to evaluate this every time as the onchange is not
* fired inside background pages, due to https://crbug.com/968651 */
const useDarkIcon =
typeof dark === 'boolean'
? dark
: window.matchMedia('(prefers-color-scheme: dark)').matches
if (useDarkIcon) {
iconPath += '_dark'
}
if (ENV_IS_FIREFOX) {
return iconPath + '.svg'
}
const iconSizes = ['16', '24', '32', '48']
if (!ENV_IS_EDGE) {
iconSizes.push('19', '38')
}
const iconPaths = {}
for (let i = 0; i < iconSizes.length; i++) {
const iconSize = iconSizes[i]
iconPaths[iconSize] = iconPath + '-' + iconSize + '.png'
}
return iconPaths
}
function updateActionIcon(tabId, active, dark) {
browserActionApi.setIcon({
path: getIconPath(active, dark),
tabId: tabId,
})
}
function getActionableState(tab) {
if (tab.status !== 'complete') return false
const tabUrl = tab.pendingUrl || tab.url
if (!tabUrl) return false
if (!tabUrl.startsWith('https://') && !tabUrl.startsWith('http://'))
return false
if (
tabUrl.startsWith('https://omnivore.app/') &&
tabUrl.startsWith('https://dev.omnivore.app/')
)
return false
return true
}
function reflectIconState(tab) {
const tabId = tab && tab.id
if (!tabId) return
const active = getActionableState(tab)
updateActionIcon(tabId, active)
}
// function updateLabelsCache(tab) {
// const query = JSON.stringify({
// query: `query GetLabels {
// labels {
// ... on LabelsSuccess {
// labels {
// ...LabelFields
// }
// }
// ... on LabelsError {
// errorCodes
// }
// }
// }
// fragment LabelFields on Label {
// id
// name
// color
// description
// createdAt
// }
// `,
// })
// return fetch(omnivoreGraphqlURL + 'graphql', {
// method: 'POST',
// redirect: 'follow',
// credentials: 'include',
// mode: 'cors',
// headers: {
// Accept: 'application/json',
// 'Content-Type': 'application/json',
// },
// body: query,
// })
// .then((response) => response.json())
// .then((data) => {
// const result = data.data.labels.labels
// return result
// })
// .then((labels) => {
// setStorage({
// labels: labels,
// labelsLastUpdated: new Date().toISOString(),
// })
// return labels
// })
// .then((labels) => {
// browserApi.tabs.sendMessage(tab.id, {
// action: ACTIONS.LabelCacheUpdated,
// payload: {},
// })
// })
// .catch((err) => {
// console.error('error fetching labels', err, omnivoreGraphqlURL)
// return undefined
// })
// }
function init() {
/* Extension icon switcher on page/tab load status */
browserApi.tabs.onActivated.addListener(({ tabId }) => {
// Due to a chrome bug, chrome.tabs.* may run into an error because onActivated is triggered too fast.
function checkCurrentTab() {
browserApi.tabs.get(tabId, function (tab) {
if (browserApi.runtime.lastError) {
setTimeout(checkCurrentTab, 150)
}
reflectIconState(tab)
})
}
checkCurrentTab()
})
/* Extension icon switcher on page/tab load status */
browserApi.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
/* Not an update while this tab is active so we skip updating icon */
if (!changeInfo.status || !tab || !tab.active) return
reflectIconState(tab)
})
browserApi.tabs.onRemoved.addListener((tabId) => {
/* cleanup any previous saveInProgress state for the tab */
cleanupTabState(tabId)
})
browserActionApi.onClicked.addListener(handleActionClick)
// forward messages from grab-iframe-content.js script to tabs
browserApi.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.forwardToTab) {
delete request.forwardToTab
browserApi.tabs.sendRequest(sender.tab.id, request)
return
}
if (request.action === ACTIONS.RefreshDarkMode) {
updateActionIcon(sender.tab.id, request.payload.value)
}
if (request.action === ACTIONS.EditTitle) {
updatePageTitle(
omnivoreGraphqlURL + 'graphql',
request.payload.pageId,
request.payload.title
).then(() => {
browserApi.tabs.sendMessage(sender.tab.id, {
action: ACTIONS.UpdateStatus,
payload: {
target: 'title',
status: 'success',
},
})
})
}
if (request.action === ACTIONS.SetLabels) {
setLabels(
omnivoreGraphqlURL + 'graphql',
request.payload.pageId,
request.payload.labelIds
).then(() => {
browserApi.tabs.sendMessage(sender.tab.id, {
action: ACTIONS.UpdateStatus,
payload: {
target: 'labels',
status: 'success',
},
})
})
}
})
// set initial extension icon
browserActionApi.setIcon({
path: getIconPath(true),
})
browserApi.contextMenus.create({
id: 'save-selection',
title: 'Save to Omnivore',
contexts: ['link'],
onclick: async function (obj) {
executeAction(async function (currentTab) {
await saveUrl(currentTab, obj.linkUrl)
})
},
})
}
init()