diff --git a/packages/yoroi-extension/chrome/content-scripts/inject.js b/packages/yoroi-extension/chrome/content-scripts/inject.js index 4f0f74dc189..14e9a586fff 100644 --- a/packages/yoroi-extension/chrome/content-scripts/inject.js +++ b/packages/yoroi-extension/chrome/content-scripts/inject.js @@ -131,6 +131,24 @@ function listenToBackgroundServiceWorker() { connected = true; } +const RETRY_COUNT = 1; +const RETRY_DELAY = 3000; + +async function sendMessageToBackground(message) { + for (let c = 0; c <= RETRY_COUNT; c++) { + try { + return chrome.runtime.sendMessage(message); + } catch (error) { + if (error.message.includes('Could not establish connection. Receiving end does not exist.')) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); + continue; + } else { + throw error; + } + } + } +} + async function handleConnectorConnectRequest(event, protocol) { const requestIdentification = event.data.requestIdentification; if ((cardanoApiInjected && !requestIdentification) && connected) { @@ -158,7 +176,7 @@ async function handleConnectorConnectRequest(event, protocol) { }, protocol, }; - chrome.runtime.sendMessage(message); + sendMessageToBackground(message); }); } } @@ -169,7 +187,7 @@ async function handleConnectorRpcRequest(event) { listenToBackgroundServiceWorker(); } try { - await chrome.runtime.sendMessage(event.data); + await sendMessageToBackground(event.data); } catch (e) { console.error(`Could not send RPC to Yoroi: ${e}`); window.postMessage({ diff --git a/packages/yoroi-extension/dev1/js/inject.js b/packages/yoroi-extension/dev1/js/inject.js new file mode 100644 index 00000000000..eb3b31e29ee --- /dev/null +++ b/packages/yoroi-extension/dev1/js/inject.js @@ -0,0 +1,251 @@ +// sets up RPC communication with the connector + access check/request functions + +const INJECTED_TYPE_TAG_ID = '__yoroi_connector_api_injected_type' +const YOROI_TYPE = 'dev'; + +const API_INTERNAL_ERROR = -2; +const API_REFUSED = -3; + +function checkInjectionInDocument() { + const el = document.getElementById(INJECTED_TYPE_TAG_ID); + return el ? el.value : 'nothing'; +} + +function markInjectionInDocument(container) { + const inp = document.createElement('input'); + inp.setAttribute('type', 'hidden'); + inp.setAttribute('id', INJECTED_TYPE_TAG_ID); + inp.setAttribute('value', YOROI_TYPE); + container.appendChild(inp); +} + +let resolveScriptedInject; + +// +// The function been changed to async, but it's still used to return a boolean flag +// Ideally it needs to be updated to use proper reject +// But all callers then need to update to use proper `then`, or `onSuccess` and `onFailure` +async function injectIntoPage(code) { + return new Promise((resolve, reject) => { + try { + const container = document.head || document.documentElement; + const scriptTag = document.createElement('script'); + scriptTag.setAttribute("async", "false"); + scriptTag.src = chrome.runtime.getURL(`js/${code}.js`); + resolveScriptedInject = () => resolve(true); + container.insertBefore(scriptTag, container.children[0]); + container.removeChild(scriptTag); + console.log(`[yoroi/${YOROI_TYPE}] dapp-connector is successfully injected into ${location.hostname}`); + markInjectionInDocument(container); + } catch (e) { + console.error(`[yoroi/${YOROI_TYPE}] injection failed!`, e); + resolve(false); + } + }); +} + +function buildTypePrecedence(buildType) { + switch (buildType) { + case 'dev': return 2; + case 'nightly': return 1; + case 'prod': return 0; + default: return -1; + } +} + +function shouldInject() { + const documentElement = document.documentElement.nodeName + const docElemCheck = documentElement ? documentElement.toLowerCase() === 'html' : true; + const { docType } = window.document; + const docTypeCheck = docType ? docType.name === 'html' : true; + if (docElemCheck && docTypeCheck) { + console.debug(`[yoroi/${YOROI_TYPE}] checking if should inject dapp-connector api`); + const existingBuildType = checkInjectionInDocument(); + if (buildTypePrecedence(YOROI_TYPE) >= buildTypePrecedence(existingBuildType)) { + console.debug(`[yoroi/${YOROI_TYPE}] injecting over '${existingBuildType}'`); + return true + } + } + return false; +} + +/** + * We can't get the favicon using the Chrome extension API + * because getting the favicon for the current tab requires the "tabs" permission + * which we don't use in the connector + * So instead, we use this heuristic + */ +function getFavicons(url) { + const defaultFavicon = `${url}/favicon.ico`; + // sometimes the favicon is specified at the top of the HTML + const optionalFavicon = document.querySelector("link[rel~='icon']"); + if(optionalFavicon) { + return [defaultFavicon, optionalFavicon.href] + } + return [defaultFavicon]; +} + +let connected = false; +let cardanoApiInjected = false; + +function disconnectWallet(protocol) { + connected = false; + window.dispatchEvent(new Event("yoroi_wallet_disconnected")); +} + +function listenToBackgroundServiceWorker() { + const connectedProtocolHolder = []; + chrome.runtime.onMessage.addListener(async (message) => { + // alert("content script message: " + JSON.stringify(message)); + if (message.type === "connector_rpc_response") { + window.postMessage(message, location.origin); + } else if (message.type === "yoroi_connect_response/cardano") { + if (message.success) { + connectedProtocolHolder[0] = 'cardano'; + if (!cardanoApiInjected) { + // inject full API here + if (await injectIntoPage('cardanoApiInject')) { + cardanoApiInjected = true; + } else { + console.error() + window.postMessage({ + type: "connector_connected", + err: { + code: API_INTERNAL_ERROR, + info: "failed to inject Cardano API" + } + }, location.origin); + } + } + } + window.postMessage({ + type: "connector_connected", + success: message.success, + auth: message.auth, + err: message.err, + }, location.origin); + } else if (message.type === 'disconnect') { + disconnectWallet(connectedProtocolHolder[0]); + } + }); + connected = true; +} + +async function handleConnectorConnectRequest(event, protocol) { + const requestIdentification = event.data.requestIdentification; + if ((cardanoApiInjected && !requestIdentification) && connected) { + // we can skip communication - API injected + hasn't been disconnected + window.postMessage({ + type: "connector_connected", + success: true + }, location.origin); + } else { + if (!connected) { + listenToBackgroundServiceWorker(); + } + // note: content scripts are subject to the same CORS policy as the website they are embedded in + // but since we are querying the website this script is injected into, it should be fine + convertImgToBase64(location.origin, getFavicons(location.origin)) + .then(imgBase64Url => { + const message = { + imgBase64Url, + // Protocol + type: `yoroi_connect_request/${protocol}`, + connectParameters: { + url: location.hostname, + requestIdentification, + onlySilent: event.data.onlySilent, + }, + protocol, + }; + chrome.runtime.sendMessage(message); + }); + } +} + +async function handleConnectorRpcRequest(event) { + console.debug("connector received from page: " + JSON.stringify(event.data) + " with source = " + event.source + " and origin = " + event.origin); + if (event.data.function === 'is_enabled/cardano' && !connected) { + listenToBackgroundServiceWorker(); + } + try { + await chrome.runtime.sendMessage(event.data); + } catch (e) { + console.error(`Could not send RPC to Yoroi: ${e}`); + window.postMessage({ + type: "connector_rpc_response", + uid: event.data.uid, + return: { + err: { + code: API_INTERNAL_ERROR, + info: `Could not send RPC to Yoroi: ${e}` + } + } + }, location.origin); + } +} + +async function connectorEventListener(event) { + const dataType = event.data.type; + if (dataType === "connector_rpc_request") { + await handleConnectorRpcRequest(event); + } else if (dataType === 'connector_connect_request/cardano') { + const protocol = dataType.split('/')[1]; + await handleConnectorConnectRequest(event, protocol); + } else if (dataType === 'scripted_injected') { + resolveScriptedInject(); + } +} + +if (shouldInject()) { + if (injectIntoPage('initialInject')) { + // events from page (injected code) + window.addEventListener("message", connectorEventListener); + } +} + +/** + * Returns a PNG base64 encoding of the favicon + * but returns empty string if no favicon is set for the page + */ +async function convertImgToBase64(origin, urls) { + let response; + for (url of urls) { + try { + const mode = url.includes(origin) ? 'same-origin' : 'no-cors'; + response = await fetch(url, { mode }); + break; + } catch (e) { + if (String(e).includes('Failed to fetch')) { + console.warn(`[yoroi-connector] Failed to fetch favicon at '${url}'`); + continue; + } + console.error(`[yoroi-connector] Failed to fetch favicon at '${url}'`, e); + // throw e; + } + } + if (!response) { + console.warn(`[yoroi-connector] No downloadable favicon found `); + return ''; + } + const blob = await response.blob(); + + const reader = new FileReader(); + await new Promise((resolve, reject) => { + reader.onload = resolve; + reader.onerror = () => resolve(''); + reader.readAsDataURL(blob); + }); + return reader.result; +} + +// relay Banxa/Encryptus callback to the extension tab +window.addEventListener('message', function (event) { + if ( + event.source === window && + /https:\/\/([a-z-]+\.)?yoroi-?wallet\.com/.test(event.origin) && + event.data?.type === 'exchange callback' + ) { + chrome.runtime.sendMessage(event.data); + } +}); diff --git a/packages/yoroi-extension/dev2/js/inject.js b/packages/yoroi-extension/dev2/js/inject.js new file mode 100644 index 00000000000..8956a0df1d7 --- /dev/null +++ b/packages/yoroi-extension/dev2/js/inject.js @@ -0,0 +1,251 @@ +// sets up RPC communication with the connector + access check/request functions + +const INJECTED_TYPE_TAG_ID = '__yoroi_connector_api_injected_type' +const YOROI_TYPE = 'dev'; + +const API_INTERNAL_ERROR = -2; +const API_REFUSED = -3; + +function checkInjectionInDocument() { + const el = document.getElementById(INJECTED_TYPE_TAG_ID); + return el ? el.value : 'nothing'; +} + +function markInjectionInDocument(container) { + const inp = document.createElement('input'); + inp.setAttribute('type', 'hidden'); + inp.setAttribute('id', INJECTED_TYPE_TAG_ID); + inp.setAttribute('value', YOROI_TYPE); + container.appendChild(inp); +} + +let resolveScriptedInject; + +// +// The function been changed to async, but it's still used to return a boolean flag +// Ideally it needs to be updated to use proper reject +// But all callers then need to update to use proper `then`, or `onSuccess` and `onFailure` +async function injectIntoPage(code) { + return new Promise((resolve, reject) => { + try { + const container = document.head || document.documentElement; + const scriptTag = document.createElement('script'); + scriptTag.setAttribute("async", "false"); + scriptTag.src = chrome.runtime.getURL(`js/${code}.js`); + resolveScriptedInject = () => resolve(true); + container.insertBefore(scriptTag, container.children[0]); + container.removeChild(scriptTag); + console.log(`[yoroi/${YOROI_TYPE}] dapp-connector is successfully injected into ${location.hostname}`); + markInjectionInDocument(container); + } catch (e) { + console.error(`[yoroi/${YOROI_TYPE}] injection failed!`, e); + resolve(false); + } + }); +} + +function buildTypePrecedence(buildType) { + switch (buildType) { + case 'dev': return 2; + case 'nightly': return 1; + case 'prod': return 0; + default: return -1; + } +} + +function shouldInject() { + const documentElement = document.documentElement.nodeName + const docElemCheck = documentElement ? documentElement.toLowerCase() === 'html' : true; + const { docType } = window.document; + const docTypeCheck = docType ? docType.name === 'html' : true; + if (docElemCheck && docTypeCheck) { + console.debug(`[yoroi/${YOROI_TYPE}] checking if should inject dapp-connector api`); + const existingBuildType = checkInjectionInDocument(); + if (buildTypePrecedence(YOROI_TYPE) >= buildTypePrecedence(existingBuildType)) { + console.debug(`[yoroi/${YOROI_TYPE}] injecting over '${existingBuildType}'`); + return true + } + } + return false; +} + +/** + * We can't get the favicon using the Chrome extension API + * because getting the favicon for the current tab requires the "tabs" permission + * which we don't use in the connector + * So instead, we use this heuristic + */ +function getFavicons(url) { + const defaultFavicon = `${url}/favicon.ico`; + // sometimes the favicon is specified at the top of the HTML + const optionalFavicon = document.querySelector("link[rel~='icon']"); + if(optionalFavicon) { + return [optionalFavicon.href, defaultFavicon] + } + return [defaultFavicon]; +} + +let connected = false; +let cardanoApiInjected = false; + +function disconnectWallet(protocol) { + connected = false; + window.dispatchEvent(new Event("yoroi_wallet_disconnected")); +} + +function listenToBackgroundServiceWorker() { + const connectedProtocolHolder = []; + chrome.runtime.onMessage.addListener(async (message) => { + // alert("content script message: " + JSON.stringify(message)); + if (message.type === "connector_rpc_response") { + window.postMessage(message, location.origin); + } else if (message.type === "yoroi_connect_response/cardano") { + if (message.success) { + connectedProtocolHolder[0] = 'cardano'; + if (!cardanoApiInjected) { + // inject full API here + if (await injectIntoPage('cardanoApiInject')) { + cardanoApiInjected = true; + } else { + console.error() + window.postMessage({ + type: "connector_connected", + err: { + code: API_INTERNAL_ERROR, + info: "failed to inject Cardano API" + } + }, location.origin); + } + } + } + window.postMessage({ + type: "connector_connected", + success: message.success, + auth: message.auth, + err: message.err, + }, location.origin); + } else if (message.type === 'disconnect') { + disconnectWallet(connectedProtocolHolder[0]); + } + }); + connected = true; +} + +async function handleConnectorConnectRequest(event, protocol) { + const requestIdentification = event.data.requestIdentification; + if ((cardanoApiInjected && !requestIdentification) && connected) { + // we can skip communication - API injected + hasn't been disconnected + window.postMessage({ + type: "connector_connected", + success: true + }, location.origin); + } else { + if (!connected) { + listenToBackgroundServiceWorker(); + } + // note: content scripts are subject to the same CORS policy as the website they are embedded in + // but since we are querying the website this script is injected into, it should be fine + convertImgToBase64(location.origin, getFavicons(location.origin)) + .then(imgBase64Url => { + const message = { + imgBase64Url, + // Protocol + type: `yoroi_connect_request/${protocol}`, + connectParameters: { + url: location.hostname, + requestIdentification, + onlySilent: event.data.onlySilent, + }, + protocol, + }; + chrome.runtime.sendMessage(message); + }); + } +} + +async function handleConnectorRpcRequest(event) { + console.debug("connector received from page: " + JSON.stringify(event.data) + " with source = " + event.source + " and origin = " + event.origin); + if (event.data.function === 'is_enabled/cardano' && !connected) { + listenToBackgroundServiceWorker(); + } + try { + await chrome.runtime.sendMessage(event.data); + } catch (e) { + console.error(`Could not send RPC to Yoroi: ${e}`); + window.postMessage({ + type: "connector_rpc_response", + uid: event.data.uid, + return: { + err: { + code: API_INTERNAL_ERROR, + info: `Could not send RPC to Yoroi: ${e}` + } + } + }, location.origin); + } +} + +async function connectorEventListener(event) { + const dataType = event.data.type; + if (dataType === "connector_rpc_request") { + await handleConnectorRpcRequest(event); + } else if (dataType === 'connector_connect_request/cardano') { + const protocol = dataType.split('/')[1]; + await handleConnectorConnectRequest(event, protocol); + } else if (dataType === 'scripted_injected') { + resolveScriptedInject(); + } +} + +if (shouldInject()) { + if (injectIntoPage('initialInject')) { + // events from page (injected code) + window.addEventListener("message", connectorEventListener); + } +} + +/** + * Returns a PNG base64 encoding of the favicon + * but returns empty string if no favicon is set for the page + */ +async function convertImgToBase64(origin, urls) { + let response; + for (url of urls) { + try { + const mode = url.includes(origin) ? 'same-origin' : 'no-cors'; + response = await fetch(url, { mode }); + break; + } catch (e) { + if (String(e).includes('Failed to fetch')) { + console.warn(`[yoroi-connector] Failed to fetch favicon at '${url}'`); + continue; + } + console.error(`[yoroi-connector] Failed to fetch favicon at '${url}'`, e); + // throw e; + } + } + if (!response) { + console.warn(`[yoroi-connector] No downloadable favicon found `); + return ''; + } + const blob = await response.blob(); + + const reader = new FileReader(); + await new Promise((resolve, reject) => { + reader.onload = resolve; + reader.onerror = () => resolve(''); + reader.readAsDataURL(blob); + }); + return reader.result; +} + +// relay Banxa/Encryptus callback to the extension tab +window.addEventListener('message', function (event) { + if ( + event.source === window && + /https:\/\/([a-z-]+\.)?yoroi-?wallet\.com/.test(event.origin) && + event.data?.type === 'exchange callback' + ) { + chrome.runtime.sendMessage(event.data); + } +}); diff --git a/packages/yoroi-extension/dev3/js/inject.js b/packages/yoroi-extension/dev3/js/inject.js new file mode 100644 index 00000000000..8956a0df1d7 --- /dev/null +++ b/packages/yoroi-extension/dev3/js/inject.js @@ -0,0 +1,251 @@ +// sets up RPC communication with the connector + access check/request functions + +const INJECTED_TYPE_TAG_ID = '__yoroi_connector_api_injected_type' +const YOROI_TYPE = 'dev'; + +const API_INTERNAL_ERROR = -2; +const API_REFUSED = -3; + +function checkInjectionInDocument() { + const el = document.getElementById(INJECTED_TYPE_TAG_ID); + return el ? el.value : 'nothing'; +} + +function markInjectionInDocument(container) { + const inp = document.createElement('input'); + inp.setAttribute('type', 'hidden'); + inp.setAttribute('id', INJECTED_TYPE_TAG_ID); + inp.setAttribute('value', YOROI_TYPE); + container.appendChild(inp); +} + +let resolveScriptedInject; + +// +// The function been changed to async, but it's still used to return a boolean flag +// Ideally it needs to be updated to use proper reject +// But all callers then need to update to use proper `then`, or `onSuccess` and `onFailure` +async function injectIntoPage(code) { + return new Promise((resolve, reject) => { + try { + const container = document.head || document.documentElement; + const scriptTag = document.createElement('script'); + scriptTag.setAttribute("async", "false"); + scriptTag.src = chrome.runtime.getURL(`js/${code}.js`); + resolveScriptedInject = () => resolve(true); + container.insertBefore(scriptTag, container.children[0]); + container.removeChild(scriptTag); + console.log(`[yoroi/${YOROI_TYPE}] dapp-connector is successfully injected into ${location.hostname}`); + markInjectionInDocument(container); + } catch (e) { + console.error(`[yoroi/${YOROI_TYPE}] injection failed!`, e); + resolve(false); + } + }); +} + +function buildTypePrecedence(buildType) { + switch (buildType) { + case 'dev': return 2; + case 'nightly': return 1; + case 'prod': return 0; + default: return -1; + } +} + +function shouldInject() { + const documentElement = document.documentElement.nodeName + const docElemCheck = documentElement ? documentElement.toLowerCase() === 'html' : true; + const { docType } = window.document; + const docTypeCheck = docType ? docType.name === 'html' : true; + if (docElemCheck && docTypeCheck) { + console.debug(`[yoroi/${YOROI_TYPE}] checking if should inject dapp-connector api`); + const existingBuildType = checkInjectionInDocument(); + if (buildTypePrecedence(YOROI_TYPE) >= buildTypePrecedence(existingBuildType)) { + console.debug(`[yoroi/${YOROI_TYPE}] injecting over '${existingBuildType}'`); + return true + } + } + return false; +} + +/** + * We can't get the favicon using the Chrome extension API + * because getting the favicon for the current tab requires the "tabs" permission + * which we don't use in the connector + * So instead, we use this heuristic + */ +function getFavicons(url) { + const defaultFavicon = `${url}/favicon.ico`; + // sometimes the favicon is specified at the top of the HTML + const optionalFavicon = document.querySelector("link[rel~='icon']"); + if(optionalFavicon) { + return [optionalFavicon.href, defaultFavicon] + } + return [defaultFavicon]; +} + +let connected = false; +let cardanoApiInjected = false; + +function disconnectWallet(protocol) { + connected = false; + window.dispatchEvent(new Event("yoroi_wallet_disconnected")); +} + +function listenToBackgroundServiceWorker() { + const connectedProtocolHolder = []; + chrome.runtime.onMessage.addListener(async (message) => { + // alert("content script message: " + JSON.stringify(message)); + if (message.type === "connector_rpc_response") { + window.postMessage(message, location.origin); + } else if (message.type === "yoroi_connect_response/cardano") { + if (message.success) { + connectedProtocolHolder[0] = 'cardano'; + if (!cardanoApiInjected) { + // inject full API here + if (await injectIntoPage('cardanoApiInject')) { + cardanoApiInjected = true; + } else { + console.error() + window.postMessage({ + type: "connector_connected", + err: { + code: API_INTERNAL_ERROR, + info: "failed to inject Cardano API" + } + }, location.origin); + } + } + } + window.postMessage({ + type: "connector_connected", + success: message.success, + auth: message.auth, + err: message.err, + }, location.origin); + } else if (message.type === 'disconnect') { + disconnectWallet(connectedProtocolHolder[0]); + } + }); + connected = true; +} + +async function handleConnectorConnectRequest(event, protocol) { + const requestIdentification = event.data.requestIdentification; + if ((cardanoApiInjected && !requestIdentification) && connected) { + // we can skip communication - API injected + hasn't been disconnected + window.postMessage({ + type: "connector_connected", + success: true + }, location.origin); + } else { + if (!connected) { + listenToBackgroundServiceWorker(); + } + // note: content scripts are subject to the same CORS policy as the website they are embedded in + // but since we are querying the website this script is injected into, it should be fine + convertImgToBase64(location.origin, getFavicons(location.origin)) + .then(imgBase64Url => { + const message = { + imgBase64Url, + // Protocol + type: `yoroi_connect_request/${protocol}`, + connectParameters: { + url: location.hostname, + requestIdentification, + onlySilent: event.data.onlySilent, + }, + protocol, + }; + chrome.runtime.sendMessage(message); + }); + } +} + +async function handleConnectorRpcRequest(event) { + console.debug("connector received from page: " + JSON.stringify(event.data) + " with source = " + event.source + " and origin = " + event.origin); + if (event.data.function === 'is_enabled/cardano' && !connected) { + listenToBackgroundServiceWorker(); + } + try { + await chrome.runtime.sendMessage(event.data); + } catch (e) { + console.error(`Could not send RPC to Yoroi: ${e}`); + window.postMessage({ + type: "connector_rpc_response", + uid: event.data.uid, + return: { + err: { + code: API_INTERNAL_ERROR, + info: `Could not send RPC to Yoroi: ${e}` + } + } + }, location.origin); + } +} + +async function connectorEventListener(event) { + const dataType = event.data.type; + if (dataType === "connector_rpc_request") { + await handleConnectorRpcRequest(event); + } else if (dataType === 'connector_connect_request/cardano') { + const protocol = dataType.split('/')[1]; + await handleConnectorConnectRequest(event, protocol); + } else if (dataType === 'scripted_injected') { + resolveScriptedInject(); + } +} + +if (shouldInject()) { + if (injectIntoPage('initialInject')) { + // events from page (injected code) + window.addEventListener("message", connectorEventListener); + } +} + +/** + * Returns a PNG base64 encoding of the favicon + * but returns empty string if no favicon is set for the page + */ +async function convertImgToBase64(origin, urls) { + let response; + for (url of urls) { + try { + const mode = url.includes(origin) ? 'same-origin' : 'no-cors'; + response = await fetch(url, { mode }); + break; + } catch (e) { + if (String(e).includes('Failed to fetch')) { + console.warn(`[yoroi-connector] Failed to fetch favicon at '${url}'`); + continue; + } + console.error(`[yoroi-connector] Failed to fetch favicon at '${url}'`, e); + // throw e; + } + } + if (!response) { + console.warn(`[yoroi-connector] No downloadable favicon found `); + return ''; + } + const blob = await response.blob(); + + const reader = new FileReader(); + await new Promise((resolve, reject) => { + reader.onload = resolve; + reader.onerror = () => resolve(''); + reader.readAsDataURL(blob); + }); + return reader.result; +} + +// relay Banxa/Encryptus callback to the extension tab +window.addEventListener('message', function (event) { + if ( + event.source === window && + /https:\/\/([a-z-]+\.)?yoroi-?wallet\.com/.test(event.origin) && + event.data?.type === 'exchange callback' + ) { + chrome.runtime.sendMessage(event.data); + } +}); diff --git a/packages/yoroi-extension/dev4/js/inject.js b/packages/yoroi-extension/dev4/js/inject.js new file mode 100644 index 00000000000..8956a0df1d7 --- /dev/null +++ b/packages/yoroi-extension/dev4/js/inject.js @@ -0,0 +1,251 @@ +// sets up RPC communication with the connector + access check/request functions + +const INJECTED_TYPE_TAG_ID = '__yoroi_connector_api_injected_type' +const YOROI_TYPE = 'dev'; + +const API_INTERNAL_ERROR = -2; +const API_REFUSED = -3; + +function checkInjectionInDocument() { + const el = document.getElementById(INJECTED_TYPE_TAG_ID); + return el ? el.value : 'nothing'; +} + +function markInjectionInDocument(container) { + const inp = document.createElement('input'); + inp.setAttribute('type', 'hidden'); + inp.setAttribute('id', INJECTED_TYPE_TAG_ID); + inp.setAttribute('value', YOROI_TYPE); + container.appendChild(inp); +} + +let resolveScriptedInject; + +// +// The function been changed to async, but it's still used to return a boolean flag +// Ideally it needs to be updated to use proper reject +// But all callers then need to update to use proper `then`, or `onSuccess` and `onFailure` +async function injectIntoPage(code) { + return new Promise((resolve, reject) => { + try { + const container = document.head || document.documentElement; + const scriptTag = document.createElement('script'); + scriptTag.setAttribute("async", "false"); + scriptTag.src = chrome.runtime.getURL(`js/${code}.js`); + resolveScriptedInject = () => resolve(true); + container.insertBefore(scriptTag, container.children[0]); + container.removeChild(scriptTag); + console.log(`[yoroi/${YOROI_TYPE}] dapp-connector is successfully injected into ${location.hostname}`); + markInjectionInDocument(container); + } catch (e) { + console.error(`[yoroi/${YOROI_TYPE}] injection failed!`, e); + resolve(false); + } + }); +} + +function buildTypePrecedence(buildType) { + switch (buildType) { + case 'dev': return 2; + case 'nightly': return 1; + case 'prod': return 0; + default: return -1; + } +} + +function shouldInject() { + const documentElement = document.documentElement.nodeName + const docElemCheck = documentElement ? documentElement.toLowerCase() === 'html' : true; + const { docType } = window.document; + const docTypeCheck = docType ? docType.name === 'html' : true; + if (docElemCheck && docTypeCheck) { + console.debug(`[yoroi/${YOROI_TYPE}] checking if should inject dapp-connector api`); + const existingBuildType = checkInjectionInDocument(); + if (buildTypePrecedence(YOROI_TYPE) >= buildTypePrecedence(existingBuildType)) { + console.debug(`[yoroi/${YOROI_TYPE}] injecting over '${existingBuildType}'`); + return true + } + } + return false; +} + +/** + * We can't get the favicon using the Chrome extension API + * because getting the favicon for the current tab requires the "tabs" permission + * which we don't use in the connector + * So instead, we use this heuristic + */ +function getFavicons(url) { + const defaultFavicon = `${url}/favicon.ico`; + // sometimes the favicon is specified at the top of the HTML + const optionalFavicon = document.querySelector("link[rel~='icon']"); + if(optionalFavicon) { + return [optionalFavicon.href, defaultFavicon] + } + return [defaultFavicon]; +} + +let connected = false; +let cardanoApiInjected = false; + +function disconnectWallet(protocol) { + connected = false; + window.dispatchEvent(new Event("yoroi_wallet_disconnected")); +} + +function listenToBackgroundServiceWorker() { + const connectedProtocolHolder = []; + chrome.runtime.onMessage.addListener(async (message) => { + // alert("content script message: " + JSON.stringify(message)); + if (message.type === "connector_rpc_response") { + window.postMessage(message, location.origin); + } else if (message.type === "yoroi_connect_response/cardano") { + if (message.success) { + connectedProtocolHolder[0] = 'cardano'; + if (!cardanoApiInjected) { + // inject full API here + if (await injectIntoPage('cardanoApiInject')) { + cardanoApiInjected = true; + } else { + console.error() + window.postMessage({ + type: "connector_connected", + err: { + code: API_INTERNAL_ERROR, + info: "failed to inject Cardano API" + } + }, location.origin); + } + } + } + window.postMessage({ + type: "connector_connected", + success: message.success, + auth: message.auth, + err: message.err, + }, location.origin); + } else if (message.type === 'disconnect') { + disconnectWallet(connectedProtocolHolder[0]); + } + }); + connected = true; +} + +async function handleConnectorConnectRequest(event, protocol) { + const requestIdentification = event.data.requestIdentification; + if ((cardanoApiInjected && !requestIdentification) && connected) { + // we can skip communication - API injected + hasn't been disconnected + window.postMessage({ + type: "connector_connected", + success: true + }, location.origin); + } else { + if (!connected) { + listenToBackgroundServiceWorker(); + } + // note: content scripts are subject to the same CORS policy as the website they are embedded in + // but since we are querying the website this script is injected into, it should be fine + convertImgToBase64(location.origin, getFavicons(location.origin)) + .then(imgBase64Url => { + const message = { + imgBase64Url, + // Protocol + type: `yoroi_connect_request/${protocol}`, + connectParameters: { + url: location.hostname, + requestIdentification, + onlySilent: event.data.onlySilent, + }, + protocol, + }; + chrome.runtime.sendMessage(message); + }); + } +} + +async function handleConnectorRpcRequest(event) { + console.debug("connector received from page: " + JSON.stringify(event.data) + " with source = " + event.source + " and origin = " + event.origin); + if (event.data.function === 'is_enabled/cardano' && !connected) { + listenToBackgroundServiceWorker(); + } + try { + await chrome.runtime.sendMessage(event.data); + } catch (e) { + console.error(`Could not send RPC to Yoroi: ${e}`); + window.postMessage({ + type: "connector_rpc_response", + uid: event.data.uid, + return: { + err: { + code: API_INTERNAL_ERROR, + info: `Could not send RPC to Yoroi: ${e}` + } + } + }, location.origin); + } +} + +async function connectorEventListener(event) { + const dataType = event.data.type; + if (dataType === "connector_rpc_request") { + await handleConnectorRpcRequest(event); + } else if (dataType === 'connector_connect_request/cardano') { + const protocol = dataType.split('/')[1]; + await handleConnectorConnectRequest(event, protocol); + } else if (dataType === 'scripted_injected') { + resolveScriptedInject(); + } +} + +if (shouldInject()) { + if (injectIntoPage('initialInject')) { + // events from page (injected code) + window.addEventListener("message", connectorEventListener); + } +} + +/** + * Returns a PNG base64 encoding of the favicon + * but returns empty string if no favicon is set for the page + */ +async function convertImgToBase64(origin, urls) { + let response; + for (url of urls) { + try { + const mode = url.includes(origin) ? 'same-origin' : 'no-cors'; + response = await fetch(url, { mode }); + break; + } catch (e) { + if (String(e).includes('Failed to fetch')) { + console.warn(`[yoroi-connector] Failed to fetch favicon at '${url}'`); + continue; + } + console.error(`[yoroi-connector] Failed to fetch favicon at '${url}'`, e); + // throw e; + } + } + if (!response) { + console.warn(`[yoroi-connector] No downloadable favicon found `); + return ''; + } + const blob = await response.blob(); + + const reader = new FileReader(); + await new Promise((resolve, reject) => { + reader.onload = resolve; + reader.onerror = () => resolve(''); + reader.readAsDataURL(blob); + }); + return reader.result; +} + +// relay Banxa/Encryptus callback to the extension tab +window.addEventListener('message', function (event) { + if ( + event.source === window && + /https:\/\/([a-z-]+\.)?yoroi-?wallet\.com/.test(event.origin) && + event.data?.type === 'exchange callback' + ) { + chrome.runtime.sendMessage(event.data); + } +}); diff --git a/packages/yoroi-extension/dev5/js/inject.js b/packages/yoroi-extension/dev5/js/inject.js new file mode 100644 index 00000000000..5e506bb9c51 --- /dev/null +++ b/packages/yoroi-extension/dev5/js/inject.js @@ -0,0 +1,260 @@ +// sets up RPC communication with the connector + access check/request functions + +const INJECTED_TYPE_TAG_ID = '__yoroi_connector_api_injected_type' +const YOROI_TYPE = 'dev'; + +const API_INTERNAL_ERROR = -2; +const API_REFUSED = -3; + +function checkInjectionInDocument() { + const el = document.getElementById(INJECTED_TYPE_TAG_ID); + return el ? el.value : 'nothing'; +} + +function markInjectionInDocument(container) { + const inp = document.createElement('input'); + inp.setAttribute('type', 'hidden'); + inp.setAttribute('id', INJECTED_TYPE_TAG_ID); + inp.setAttribute('value', YOROI_TYPE); + container.appendChild(inp); +} + +let resolveScriptedInject; + +// +// The function been changed to async, but it's still used to return a boolean flag +// Ideally it needs to be updated to use proper reject +// But all callers then need to update to use proper `then`, or `onSuccess` and `onFailure` +async function injectIntoPage(code) { + return new Promise((resolve, reject) => { + try { + const container = document.head || document.documentElement; + const scriptTag = document.createElement('script'); + scriptTag.setAttribute("async", "false"); + scriptTag.src = chrome.runtime.getURL(`js/${code}.js`); + resolveScriptedInject = () => resolve(true); + container.insertBefore(scriptTag, container.children[0]); + container.removeChild(scriptTag); + console.log(`[yoroi/${YOROI_TYPE}] dapp-connector is successfully injected into ${location.hostname}`); + markInjectionInDocument(container); + } catch (e) { + console.error(`[yoroi/${YOROI_TYPE}] injection failed!`, e); + resolve(false); + } + }); +} + +function buildTypePrecedence(buildType) { + switch (buildType) { + case 'dev': return 2; + case 'nightly': return 1; + case 'prod': return 0; + default: return -1; + } +} + +function shouldInject() { + const documentElement = document.documentElement.nodeName + const docElemCheck = documentElement ? documentElement.toLowerCase() === 'html' : true; + const { docType } = window.document; + const docTypeCheck = docType ? docType.name === 'html' : true; + if (docElemCheck && docTypeCheck) { + console.debug(`[yoroi/${YOROI_TYPE}] checking if should inject dapp-connector api`); + const existingBuildType = checkInjectionInDocument(); + if (buildTypePrecedence(YOROI_TYPE) >= buildTypePrecedence(existingBuildType)) { + console.debug(`[yoroi/${YOROI_TYPE}] injecting over '${existingBuildType}'`); + return true + } + } + return false; +} + +/** + * We can't get the favicon using the Chrome extension API + * because getting the favicon for the current tab requires the "tabs" permission + * which we don't use in the connector + * So instead, we use this heuristic + */ +function getFavicons(url) { + const defaultFavicon = `${url}/favicon.ico`; + // sometimes the favicon is specified at the top of the HTML + const optionalFavicon = document.querySelector("link[rel~='icon']"); + if(optionalFavicon) { + return [optionalFavicon.href, defaultFavicon] + } + return [defaultFavicon]; +} + +let connected = false; +let cardanoApiInjected = false; + +function disconnectWallet(protocol) { + connected = false; + window.dispatchEvent(new Event("yoroi_wallet_disconnected")); +} + +function listenToBackgroundServiceWorker() { + const connectedProtocolHolder = []; + chrome.runtime.onMessage.addListener(async (message) => { + // alert("content script message: " + JSON.stringify(message)); + if (message.type === "connector_rpc_response") { + window.postMessage(message, location.origin); + } else if (message.type === "yoroi_connect_response/cardano") { + if (message.success) { + connectedProtocolHolder[0] = 'cardano'; + if (!cardanoApiInjected) { + // inject full API here + if (await injectIntoPage('cardanoApiInject')) { + cardanoApiInjected = true; + } else { + console.error() + window.postMessage({ + type: "connector_connected", + err: { + code: API_INTERNAL_ERROR, + info: "failed to inject Cardano API" + } + }, location.origin); + } + } + } + window.postMessage({ + type: "connector_connected", + success: message.success, + auth: message.auth, + err: message.err, + }, location.origin); + } else if (message.type === 'disconnect') { + disconnectWallet(connectedProtocolHolder[0]); + } + }); + connected = true; +} + +async function handleConnectorConnectRequest(event, protocol) { + const requestIdentification = event.data.requestIdentification; + if ((cardanoApiInjected && !requestIdentification) && connected) { + // we can skip communication - API injected + hasn't been disconnected + window.postMessage({ + type: "connector_connected", + success: true + }, location.origin); + } else { + if (!connected) { + listenToBackgroundServiceWorker(); + } + // note: content scripts are subject to the same CORS policy as the website they are embedded in + // but since we are querying the website this script is injected into, it should be fine + convertImgToBase64(location.origin, getFavicons(location.origin)) + .then(imgBase64Url => { + const message = { + imgBase64Url, + // Protocol + type: `yoroi_connect_request/${protocol}`, + connectParameters: { + url: location.hostname, + requestIdentification, + onlySilent: event.data.onlySilent, + }, + protocol, + }; + chrome.runtime.sendMessage(message); + }); + } +} + +async function handleConnectorRpcRequest(event) { + console.debug("connector received from page: " + JSON.stringify(event.data) + " with source = " + event.source + " and origin = " + event.origin); + if (event.data.function === 'is_enabled/cardano' && !connected) { + listenToBackgroundServiceWorker(); + } + try { + await chrome.runtime.sendMessage(event.data); + } catch (e) { + console.error(`Could not send RPC to Yoroi: ${e}`); + window.postMessage({ + type: "connector_rpc_response", + uid: event.data.uid, + return: { + err: { + code: API_INTERNAL_ERROR, + info: `Could not send RPC to Yoroi: ${e}` + } + } + }, location.origin); + } +} + +async function connectorEventListener(event) { + const dataType = event.data.type; + if (dataType === "connector_rpc_request") { + await handleConnectorRpcRequest(event); + } else if (dataType === 'connector_connect_request/cardano') { + const protocol = dataType.split('/')[1]; + await handleConnectorConnectRequest(event, protocol); + } else if (dataType === 'scripted_injected') { + resolveScriptedInject(); + } else if (dataType === 'bring_rpc_request') { + await chrome.runtime.sendMessage(event.data); + } +} + +if (shouldInject()) { + if (injectIntoPage('initialInject')) { + // events from page (injected code) + window.addEventListener("message", connectorEventListener); + } +} + +/** + * Returns a PNG base64 encoding of the favicon + * but returns empty string if no favicon is set for the page + */ +async function convertImgToBase64(origin, urls) { + let response; + for (url of urls) { + try { + const mode = url.includes(origin) ? 'same-origin' : 'no-cors'; + response = await fetch(url, { mode }); + break; + } catch (e) { + if (String(e).includes('Failed to fetch')) { + console.warn(`[yoroi-connector] Failed to fetch favicon at '${url}'`); + continue; + } + console.error(`[yoroi-connector] Failed to fetch favicon at '${url}'`, e); + // throw e; + } + } + if (!response) { + console.warn(`[yoroi-connector] No downloadable favicon found `); + return ''; + } + const blob = await response.blob(); + + const reader = new FileReader(); + await new Promise((resolve, reject) => { + reader.onload = resolve; + reader.onerror = () => resolve(''); + reader.readAsDataURL(blob); + }); + return reader.result; +} + +// relay Banxa/Encryptus callback to the extension tab +window.addEventListener('message', function (event) { + if ( + event.source === window && + /https:\/\/([a-z-]+\.)?yoroi-?wallet\.com/.test(event.origin) && + event.data?.type === 'exchange callback' + ) { + chrome.runtime.sendMessage(event.data); + } +}); + +// relay message from background to Bring +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'bring_rpc_response') { + window.postMessage(message, location.origin); + } +}); diff --git a/packages/yoroi-extension/scripts/inject.js b/packages/yoroi-extension/scripts/inject.js new file mode 100644 index 00000000000..51af7675108 --- /dev/null +++ b/packages/yoroi-extension/scripts/inject.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +const path = require('path'); +const webpack = require('webpack'); + +const tasks = require('./tasks'); + +const { baseDevConfig, backgroundServiceWorkerConfig } = require(`../webpack/devConfig`); +const { argv, shouldInjectConnector, isNightly, buildAndCopyInjector } = require('./utils'); + +// override NODE_ENV for ConfigWebpackPlugin +process.env.NODE_CONFIG_ENV = argv.env; + +function devMainWindow(env: string) { + /* + console.log('[Build manifest]'); + console.log('-'.repeat(80)); + tasks.buildManifests(true, isNightly, shouldInjectConnector); + + console.log('[Copy assets]'); + console.log('-'.repeat(80)); + tasks.copyAssets('dev', env); + */ + buildAndCopyInjector('dev/js', 'dev'); +} + +devMainWindow(argv.env);