diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b9501b008..63ede4dfb 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -685,6 +685,9 @@ "etherscanView": { "message": "View account on Etherscan" }, + "example": { + "message": "Example" + }, "expandView": { "message": "Expand view" }, @@ -946,6 +949,15 @@ "ipfsGatewayDescription": { "message": "Enter the URL of the IPFS CID gateway to use for ENS content resolution." }, + "ipfsIpnsDescription": { + "message": "Now metamask will resolve # and # urls directly." + }, + "ipfsIpnsExample": { + "message": "If you type # it will be resolved as " + }, + "ipfsIpnsResolvingTitle": { + "message": "IPFS and IPNS URL resolving" + }, "jsonFile": { "message": "JSON File", "description": "format for importing an account" diff --git a/app/manifest/chrome.json b/app/manifest/chrome.json index 281c847a4..79496eb63 100644 --- a/app/manifest/chrome.json +++ b/app/manifest/chrome.json @@ -3,5 +3,8 @@ "matches": ["https://metamask.io/*"], "ids": ["*"] }, - "minimum_chrome_version": "63" + "minimum_chrome_version": "63", + "permissions": [ + "chrome-extension://*/*" + ] } diff --git a/app/manifest/firefox.json b/app/manifest/firefox.json index 930e1e758..e2b08ea4f 100644 --- a/app/manifest/firefox.json +++ b/app/manifest/firefox.json @@ -4,5 +4,22 @@ "id": "webextension@metamask.io", "strict_min_version": "68.0" } - } + }, + "protocol_handlers": [ + { + "protocol": "dweb", + "name": "IPFS Companion: DWEB Protocol Handler", + "uriTemplate": "https://dweb.link/ipfs/?uri=%s" + }, + { + "protocol": "ipfs", + "name": "IPFS Companion: IPFS Protocol Handler", + "uriTemplate": "https://dweb.link/ipfs/?uri=%s" + }, + { + "protocol": "ipns", + "name": "IPFS Companion: IPNS Protocol Handler", + "uriTemplate": "https://dweb.link/ipns/?uri=%s" + } + ] } diff --git a/app/scripts/controllers/ipfs.js b/app/scripts/controllers/ipfs.js new file mode 100644 index 000000000..9d667f946 --- /dev/null +++ b/app/scripts/controllers/ipfs.js @@ -0,0 +1,33 @@ +import { ObservableStore } from '@metamask/obs-store'; + +const DEFAULT_IPFS_IPNS_ENABLED = false; + +export default class IpfsIpnsController { + constructor() { + const initState = { + ipfsIpnsEnabled: DEFAULT_IPFS_IPNS_ENABLED, + ipfsIpnsHandlerShouldUpdate: false, + }; + this.store = new ObservableStore(initState); + } + + /** + * @param {boolean} status - indicates if ipfs ipns resolving is enabled + * @returns status of ipfs ipns url resolving + */ + setIpfsIpnsUrlResolving(status) { + this.store.updateState({ + ipfsIpnsEnabled: status, + }); + return Promise.resolve(status); + } + + /** + * @param {boolean} bool - indicates if protocol handler should be updated + * @returns bool + */ + setIpfsIpnsHandlerShouldUpdate(bool) { + this.store.updateState({ ipfsIpnsHandlerShouldUpdate: bool }); + return Promise.resolve(bool); + } +} diff --git a/app/scripts/lib/ens-ipfs/setup.js b/app/scripts/lib/ens-ipfs/setup.js index 8c30de975..5b5e72455 100644 --- a/app/scripts/lib/ens-ipfs/setup.js +++ b/app/scripts/lib/ens-ipfs/setup.js @@ -5,27 +5,61 @@ import resolveEnsToIpfsContentId from './resolver'; const fetchWithTimeout = getFetchWithTimeout(30000); const supportedTopLevelDomains = ['eth']; +const supportedProtocols = ['ipfs', 'ipns']; +const supportedBrowsers = ['chrome-extension'] export default function setupEnsIpfsResolver({ provider, getCurrentChainId, getIpfsGateway, -}) { // install listener +}) { const urlPatterns = supportedTopLevelDomains.map((tld) => `*://*.${tld}/*`); + + for (const browser of supportedBrowsers) { + // only setup ipfs ipns handler on supported browsers + if(window.location.href.startsWith(browser)) { + extension.webRequest.onBeforeRequest.addListener(ipfsIpnsUrlHandler, { + types: ['main_frame'], + urls: [`${browser}://*/*`], + }); + break; + } + } + extension.webRequest.onErrorOccurred.addListener(webRequestDidFail, { - urls: urlPatterns, types: ['main_frame'], + urls: urlPatterns, }); + // return api object return { // uninstall listener remove() { extension.webRequest.onErrorOccurred.removeListener(webRequestDidFail); + extension.webRequest.onBeforeRequest.removeListener(ipfsIpnsUrlHandler); }, }; + async function ipfsIpnsUrlHandler(details) { + const { tabId, url } = details; + // ignore requests that are not associated with tabs + if (tabId === -1) { + return; + } + + const unUrl = unescape(url); + supportedProtocols.forEach((protocol) => { + if (unUrl.includes(`${protocol}:`)) { + const identifier = unUrl.split(`${protocol}:`)[1]; + const ipfsGateway = getIpfsGateway(); + const newUrl = `https://${ipfsGateway}/${protocol}${identifier}`; + extension.tabs.update(tabId, { url: newUrl }); + } + }); + } + async function webRequestDidFail(details) { const { tabId, url } = details; // ignore requests that are not associated with tabs @@ -37,10 +71,12 @@ export default function setupEnsIpfsResolver({ const { hostname: name, pathname, search, hash: fragment } = new URL(url); const domainParts = name.split('.'); const topLevelDomain = domainParts[domainParts.length - 1]; + // if unsupported TLD, abort if (!supportedTopLevelDomains.includes(topLevelDomain)) { return; } + // otherwise attempt resolve attemptResolve({ tabId, name, pathname, search, fragment }); } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 361050d86..6d0c1430f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -70,6 +70,7 @@ import { segment, segmentLegacy } from './lib/segment'; import createMetaRPCHandler from './lib/createMetaRPCHandler'; import { WORKER_BLOB_URL } from './lib/worker-blob'; import { FILSNAP_NAME, setupFilsnap } from './lib/filsnap'; +import IpfsIpnsController from './controllers/ipfs'; export const METAMASK_CONTROLLER_EVENTS = { // Fired after state changes that impact the extension badge (unapproved msg count) @@ -459,6 +460,8 @@ export default class MetamaskController extends EventEmitter { ), }); + this.ipfsIpnsController = new IpfsIpnsController(); + // ensure accountTracker updates balances after network change this.networkController.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { this.accountTracker._updateAccounts(); @@ -495,6 +498,7 @@ export default class MetamaskController extends EventEmitter { PluginController: this.pluginController.store, ThreeBoxController: this.threeBoxController.store, AssetsController: this.assetsController.store, + IpfsIpnsController: this.ipfsIpnsController.store, }); this.memStore = new ComposableObservableStore(null, { @@ -525,6 +529,7 @@ export default class MetamaskController extends EventEmitter { EnsController: this.ensController.store, ApprovalController: this.approvalController, AssetsController: this.assetsController.store, + IpfsIpnsController: this.ipfsIpnsController.store, }); this.memStore.subscribe(this.sendUpdate.bind(this)); @@ -727,6 +732,10 @@ export default class MetamaskController extends EventEmitter { setUseNonceField: this.setUseNonceField.bind(this), setUsePhishDetect: this.setUsePhishDetect.bind(this), setIpfsGateway: this.setIpfsGateway.bind(this), + setIpfsIpnsUrlResolving: this.setIpfsIpnsUrlResolving.bind(this), + setIpfsIpnsHandlerShouldUpdate: this.setIpfsIpnsHandlerShouldUpdate.bind( + this, + ), setParticipateInMetaMetrics: this.setParticipateInMetaMetrics.bind(this), setMetaMetricsSendCount: this.setMetaMetricsSendCount.bind(this), setFirstTimeFlowType: this.setFirstTimeFlowType.bind(this), @@ -2907,6 +2916,42 @@ export default class MetamaskController extends EventEmitter { } } + /** + * Sets IPFS and IPNS URL resolving + * @param {*} bool + * @param {*} cb + * @returns + */ + setIpfsIpnsUrlResolving(bool, cb) { + try { + this.ipfsIpnsController.setIpfsIpnsUrlResolving(bool); + cb(null); + return; + } catch (err) { + cb(err); + // eslint-disable-next-line no-useless-return + return; + } + } + + /** + * Sets if protocol handlers for ipfs and ipns should be updated + * @param {*} bool + * @param {*} cb + * @returns + */ + setIpfsIpnsHandlerShouldUpdate(bool, cb) { + try { + this.ipfsIpnsController.setIpfsIpnsHandlerShouldUpdate(bool); + cb(null); + return; + } catch (err) { + cb(err); + // eslint-disable-next-line no-useless-return + return; + } + } + /** * Sets whether or not the user will have usage data tracked with MetaMetrics * @param {boolean} bool - True for users that wish to opt-in, false for users that wish to remain out. diff --git a/ui/app/helpers/constants/routes.js b/ui/app/helpers/constants/routes.js index 61b21b50c..91af96057 100644 --- a/ui/app/helpers/constants/routes.js +++ b/ui/app/helpers/constants/routes.js @@ -66,6 +66,8 @@ const DECRYPT_MESSAGE_REQUEST_PATH = '/decrypt-message-request'; const ENCRYPTION_PUBLIC_KEY_REQUEST_PATH = '/encryption-public-key-request'; const CONFIRMATION_V_NEXT_ROUTE = '/confirmation'; +const IPFS_IPNS_URL_RESOLVING = '/ipfs-ipns-resolve'; + // Used to pull a convenient name for analytics tracking events. The key must // be react-router ready path, and can include params such as :id for popup windows const PATH_NAME_MAP = { @@ -135,6 +137,7 @@ const PATH_NAME_MAP = { [LOADING_QUOTES_ROUTE]: 'Swaps Loading Quotes Page', [AWAITING_SWAP_ROUTE]: 'Swaps Awaiting Swaps Page', [SWAPS_ERROR_ROUTE]: 'Swaps Error Page', + [IPFS_IPNS_URL_RESOLVING]: 'Ipfs and Ipns URL Resolving Info Page', }; export { @@ -201,4 +204,5 @@ export { AWAITING_SWAP_ROUTE, SWAPS_ERROR_ROUTE, SWAPS_MAINTENANCE_ROUTE, + IPFS_IPNS_URL_RESOLVING, }; diff --git a/ui/app/pages/ipfs-enable/index.js b/ui/app/pages/ipfs-enable/index.js new file mode 100644 index 000000000..ec256a9d9 --- /dev/null +++ b/ui/app/pages/ipfs-enable/index.js @@ -0,0 +1 @@ +export { default } from './ipfs-enable.container'; diff --git a/ui/app/pages/ipfs-enable/index.scss b/ui/app/pages/ipfs-enable/index.scss new file mode 100644 index 000000000..763a4e1bb --- /dev/null +++ b/ui/app/pages/ipfs-enable/index.scss @@ -0,0 +1,65 @@ +.ipfs-enable { + &__container { + display: flex; + min-height: 100%; + } + + &__main-view { + flex: 1 1 66.5%; + background: $white; + min-width: 0; + display: flex; + flex-direction: column; + } + + &__menu { + display: grid; + grid-template-columns: 30% minmax(30%, 1fr) 30%; + column-gap: 5px; + padding: 0 8px; + border-bottom: 1px solid $Grey-100; + height: 64px; + } + + &__title { + grid-column: 2/span 1; + place-self: center stretch; + display: flex; + flex-direction: center; + justify-content: center; + align-items: center; + flex: 1; + } + + &__content { + display: flex; + justify-content: space-between; + align-items: center; + // flex: 1; + min-height: 209px; + padding-top: 40px; + flex-direction: column; + width: 100%; + } + + &__content-item { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + margin-bottom: 20px; + } + + &__description { + font-size: 0.875rem; + line-height: 140%; + font-style: normal; + font-weight: normal; + color: #9b9b9b; + padding-top: 5px; + } +} + +.highlight { + font-weight: bold; +} \ No newline at end of file diff --git a/ui/app/pages/ipfs-enable/ipfs-enable.component.js b/ui/app/pages/ipfs-enable/ipfs-enable.component.js new file mode 100644 index 000000000..00237a6d1 --- /dev/null +++ b/ui/app/pages/ipfs-enable/ipfs-enable.component.js @@ -0,0 +1,116 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { IPFS_IPNS_URL_RESOLVING } from '../../helpers/constants/routes'; + +export default class IpfsEnable extends PureComponent { + static contextTypes = { + t: PropTypes.func, + }; + + static get propTypes() { + return { + ipfsGateway: PropTypes.string.isRequired, + ipfsIpnsEnabled: PropTypes.bool.isRequired, + ipfsIpnsHandlerShouldUpdate: PropTypes.bool.isRequired, + setIpfsIpnsHandlerShouldUpdate: PropTypes.func, + }; + } + + state = { + ipfsGateway: this.props.ipfsGateway, + ipfsIpnsIsEnabled: this.props.ipfsIpnsEnabled, + }; + + componentDidMount() { + const { ipfsIpnsEnabled, ipfsIpnsHandlerShouldUpdate } = this.props; + + if (ipfsIpnsHandlerShouldUpdate) { + const page = IPFS_IPNS_URL_RESOLVING.replace('/', ''); + if (ipfsIpnsEnabled) { + this.registerHandlers(page); + } else { + this.unregisterHandlers(page); + } + this.props.setIpfsIpnsHandlerShouldUpdate(false); + } + } + + registerHandlers(page) { + window.navigator.registerProtocolHandler( + 'ipfs', + window.location.href.replace(`/home.html#${page}`, '/%s'), + 'Ipfs handler', + ); + window.navigator.registerProtocolHandler( + 'ipns', + window.location.href.replace(`/home.html#${page}`, '/%s'), + 'Ipns handler', + ); + } + + unregisterHandlers(page) { + window.navigator.unregisterProtocolHandler( + 'ipfs', + window.location.href.replace(`/home.html#${page}`, '/%s'), + ); + window.navigator.unregisterProtocolHandler( + 'ipns', + window.location.href.replace(`/home.html#${page}`, '/%s'), + ); + } + + render() { + const { t } = this.context; + const ipfsIpnsDescriptionParts = t('ipfsIpnsDescription').split('#'); + const ipfsIpnsExampleParts = t('ipfsIpnsExample').split('#'); + return ( +