Skip to content

Commit 7e17317

Browse files
Fix MetaMask mobile on iOS (#4229)
* Try Firefox hack for MetaMask mobile on iOS * Try manually injecting mobile provider * Dont import * Add ReactNativePostMessageStream * Add full MM mobile injection * Simplify * Remove web3 shim * Update deps * Update FF hack * Add comment * Show MM mobile links for web3 mobile install
1 parent 1e2bd04 commit 7e17317

File tree

8 files changed

+402
-74
lines changed

8 files changed

+402
-74
lines changed

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
"@ethersproject/transactions": "5.4.0",
2323
"@ethersproject/units": "5.4.0",
2424
"@ethersproject/wallet": "5.4.0",
25-
"@metamask/inpage-provider": "6.0.1",
25+
"@metamask/inpage-provider": "8.0.3",
26+
"@metamask/object-multiplex": "1.2.0",
27+
"@metamask/post-message-stream": "4.0.0",
2628
"@mycrypto/eth-scan": "3.4.4",
2729
"@mycrypto/ui": "0.24.1",
2830
"@mycrypto/unlock-scan": "1.2.0",

src/components/WalletUnlock/Web3ProviderInstall.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const AppLinkContainer = styled.div`
3131
`;
3232

3333
function InstallTrunk() {
34-
const providers = [WALLETS_CONFIG.TRUST, WALLETS_CONFIG.COINBASE];
34+
const providers = [WALLETS_CONFIG.METAMASK, WALLETS_CONFIG.COINBASE];
3535
return (
3636
<Box variant="rowAlign" justifyContent="space-between" mt={SPACING.BASE} width="100%">
3737
{providers.map((provider) => (

src/config/wallets.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ export const WALLETS_CONFIG: Record<WalletId, IWalletConfig> = {
6767
description: 'ADD_WEB3DESC',
6868
helpLink: getKBHelpArticle(MIGRATE_TO_METAMASK),
6969
install: {
70-
getItLink: 'https://metamask.io'
70+
getItLink: 'https://metamask.io',
71+
appStore: 'https://apps.apple.com/us/app/metamask/id1438144202',
72+
googlePlay: 'https://play.google.com/store/apps/details?id=io.metamask'
7173
},
7274
flags: {
7375
supportsNonce: false
+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
const { Duplex } = require('readable-stream');
2+
const { inherits } = require('util');
3+
4+
const noop = () => undefined;
5+
6+
module.exports = MobilePortStream;
7+
8+
inherits(MobilePortStream, Duplex);
9+
10+
/**
11+
* Creates a stream that's both readable and writable.
12+
* The stream supports arbitrary objects.
13+
*
14+
* @class
15+
* @param {Object} port Remote Port object
16+
*/
17+
function MobilePortStream(port) {
18+
Duplex.call(this, {
19+
objectMode: true
20+
});
21+
this._name = port.name;
22+
this._targetWindow = window;
23+
this._port = port;
24+
this._origin = location.origin;
25+
window.addEventListener('message', this._onMessage.bind(this), false);
26+
}
27+
28+
/**
29+
* Callback triggered when a message is received from
30+
* the remote Port associated with this Stream.
31+
*
32+
* @private
33+
* @param {Object} msg - Payload from the onMessage listener of Port
34+
*/
35+
MobilePortStream.prototype._onMessage = function (event) {
36+
const msg = event.data;
37+
38+
// validate message
39+
if (this._origin !== '*' && event.origin !== this._origin) {
40+
return;
41+
}
42+
if (!msg || typeof msg !== 'object') {
43+
return;
44+
}
45+
if (!msg.data || typeof msg.data !== 'object') {
46+
return;
47+
}
48+
if (msg.target && msg.target !== this._name) {
49+
return;
50+
}
51+
// Filter outgoing messages
52+
if (msg.data.data && msg.data.data.toNative) {
53+
return;
54+
}
55+
56+
if (Buffer.isBuffer(msg)) {
57+
delete msg._isBuffer;
58+
const data = Buffer.from(msg);
59+
this.push(data);
60+
} else {
61+
this.push(msg);
62+
}
63+
};
64+
65+
/**
66+
* Callback triggered when the remote Port
67+
* associated with this Stream disconnects.
68+
*
69+
* @private
70+
*/
71+
MobilePortStream.prototype._onDisconnect = function () {
72+
this.destroy();
73+
};
74+
75+
/**
76+
* Explicitly sets read operations to a no-op
77+
*/
78+
MobilePortStream.prototype._read = noop;
79+
80+
/**
81+
* Called internally when data should be written to
82+
* this writable stream.
83+
*
84+
* @private
85+
* @param {*} msg Arbitrary object to write
86+
* @param {string} encoding Encoding to use when writing payload
87+
* @param {Function} cb Called when writing is complete or an error occurs
88+
*/
89+
MobilePortStream.prototype._write = function (msg, _encoding, cb) {
90+
try {
91+
if (Buffer.isBuffer(msg)) {
92+
const data = msg.toJSON();
93+
data._isBuffer = true;
94+
window.ReactNativeWebView.postMessage(
95+
JSON.stringify({ ...data, origin: window.location.href })
96+
);
97+
} else {
98+
if (msg.data) {
99+
msg.data.toNative = true;
100+
}
101+
window.ReactNativeWebView.postMessage(
102+
JSON.stringify({ ...msg, origin: window.location.href })
103+
);
104+
}
105+
} catch (err) {
106+
return cb(new Error('MobilePortStream - disconnected'));
107+
}
108+
return cb();
109+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const { Duplex } = require('readable-stream');
2+
const { inherits } = require('util');
3+
4+
const noop = () => undefined;
5+
6+
module.exports = PostMessageStream;
7+
8+
inherits(PostMessageStream, Duplex);
9+
10+
function PostMessageStream(opts) {
11+
Duplex.call(this, {
12+
objectMode: true
13+
});
14+
15+
this._name = opts.name;
16+
this._target = opts.target;
17+
this._targetWindow = opts.targetWindow || window;
18+
this._origin = opts.targetWindow ? '*' : location.origin;
19+
20+
// initialization flags
21+
this._init = false;
22+
this._haveSyn = false;
23+
24+
window.addEventListener('message', this._onMessage.bind(this), false);
25+
// send syncorization message
26+
this._write('SYN', null, noop);
27+
this.cork();
28+
}
29+
30+
// private
31+
PostMessageStream.prototype._onMessage = function (event) {
32+
const msg = event.data;
33+
34+
// validate message
35+
if (this._origin !== '*' && event.origin !== this._origin) {
36+
return;
37+
}
38+
if (event.source !== this._targetWindow && window === top) {
39+
return;
40+
}
41+
if (!msg || typeof msg !== 'object') {
42+
return;
43+
}
44+
if (msg.target !== this._name) {
45+
return;
46+
}
47+
if (!msg.data) {
48+
return;
49+
}
50+
51+
if (this._init) {
52+
// forward message
53+
try {
54+
this.push(msg.data);
55+
} catch (err) {
56+
this.emit('error', err);
57+
}
58+
} else if (msg.data === 'SYN') {
59+
this._haveSyn = true;
60+
this._write('ACK', null, noop);
61+
} else if (msg.data === 'ACK') {
62+
this._init = true;
63+
if (!this._haveSyn) {
64+
this._write('ACK', null, noop);
65+
}
66+
this.uncork();
67+
}
68+
};
69+
70+
// stream plumbing
71+
PostMessageStream.prototype._read = noop;
72+
73+
PostMessageStream.prototype._write = function (data, _encoding, cb) {
74+
const message = {
75+
target: this._target,
76+
data
77+
};
78+
this._targetWindow.postMessage(message, this._origin);
79+
cb();
80+
};

src/vendor/inpage-metamask-mobile.js

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Based of: https://github.com/MetaMask/mobile-provider/blob/main/src/inpage/index.js
2+
3+
import { initializeProvider } from '@metamask/inpage-provider';
4+
import ObjectMultiplex from '@metamask/object-multiplex';
5+
import pump from 'pump';
6+
7+
import MobilePortStream from './MetaMask/MobilePortStream';
8+
import ReactNativePostMessageStream from './MetaMask/ReactNativePostMessageStream';
9+
10+
const INPAGE = 'metamask-inpage';
11+
const CONTENT_SCRIPT = 'metamask-contentscript';
12+
const PROVIDER = 'metamask-provider';
13+
14+
export const injectMobile = () => {
15+
// Setup stream for content script communication
16+
const metamaskStream = new ReactNativePostMessageStream({
17+
name: INPAGE,
18+
target: CONTENT_SCRIPT
19+
});
20+
21+
// Initialize provider object (window.ethereum)
22+
initializeProvider({
23+
connectionStream: metamaskStream,
24+
shouldSendMetadata: false
25+
});
26+
27+
setupProviderStreams();
28+
};
29+
30+
// Functions
31+
32+
/**
33+
* Setup function called from content script after the DOM is ready.
34+
*/
35+
function setupProviderStreams() {
36+
// the transport-specific streams for communication between inpage and background
37+
const pageStream = new ReactNativePostMessageStream({
38+
name: CONTENT_SCRIPT,
39+
target: INPAGE
40+
});
41+
42+
const appStream = new MobilePortStream({
43+
name: CONTENT_SCRIPT
44+
});
45+
46+
// create and connect channel muxes
47+
// so we can handle the channels individually
48+
const pageMux = new ObjectMultiplex();
49+
pageMux.setMaxListeners(25);
50+
const appMux = new ObjectMultiplex();
51+
appMux.setMaxListeners(25);
52+
53+
pump(pageMux, pageStream, pageMux, (err) =>
54+
logStreamDisconnectWarning('MetaMask Inpage Multiplex', err)
55+
);
56+
pump(appMux, appStream, appMux, (err) => {
57+
logStreamDisconnectWarning('MetaMask Background Multiplex', err);
58+
notifyProviderOfStreamFailure();
59+
});
60+
61+
// forward communication across inpage-background for these channels only
62+
forwardTrafficBetweenMuxes(PROVIDER, pageMux, appMux);
63+
}
64+
65+
/**
66+
* Set up two-way communication between muxes for a single, named channel.
67+
*
68+
* @param {string} channelName - The name of the channel.
69+
* @param {ObjectMultiplex} muxA - The first mux.
70+
* @param {ObjectMultiplex} muxB - The second mux.
71+
*/
72+
function forwardTrafficBetweenMuxes(channelName, muxA, muxB) {
73+
const channelA = muxA.createStream(channelName);
74+
const channelB = muxB.createStream(channelName);
75+
pump(channelA, channelB, channelA, (err) =>
76+
logStreamDisconnectWarning(`MetaMask muxed traffic for channel "${channelName}" failed.`, err)
77+
);
78+
}
79+
80+
/**
81+
* Error handler for page to extension stream disconnections
82+
*
83+
* @param {string} remoteLabel - Remote stream name
84+
* @param {Error} err - Stream connection error
85+
*/
86+
function logStreamDisconnectWarning(remoteLabel, err) {
87+
let warningMsg = `MetamaskContentscript - lost connection to ${remoteLabel}`;
88+
if (err) {
89+
warningMsg += `\n${err.stack}`;
90+
}
91+
console.warn(warningMsg);
92+
console.error(err);
93+
}
94+
95+
/**
96+
* This function must ONLY be called in pump destruction/close callbacks.
97+
* Notifies the inpage context that streams have failed, via window.postMessage.
98+
* Relies on @metamask/object-multiplex and post-message-stream implementation details.
99+
*/
100+
function notifyProviderOfStreamFailure() {
101+
window.postMessage(
102+
{
103+
target: INPAGE, // the post-message-stream "target"
104+
data: {
105+
// this object gets passed to object-multiplex
106+
name: PROVIDER, // the object-multiplex channel name
107+
data: {
108+
jsonrpc: '2.0',
109+
method: 'METAMASK_STREAM_FAILURE'
110+
}
111+
}
112+
},
113+
window.location.origin
114+
);
115+
}

src/vendor/inpage-metamask.js

+17-9
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1-
import { initProvider } from '@metamask/inpage-provider';
2-
import LocalMessageDuplexStream from 'post-message-stream';
1+
import { initializeProvider } from '@metamask/inpage-provider';
2+
import { WindowPostMessageStream } from '@metamask/post-message-stream';
33

4-
// Firefox Metamask Hack
4+
import { injectMobile } from './inpage-metamask-mobile';
5+
6+
// Metamask injection hack
57
// Due to https://github.com/MetaMask/metamask-extension/issues/3133
68

79
(() => {
8-
if (!window.ethereum && !window.web3 && navigator.userAgent.includes('Firefox')) {
10+
if (window.ethereum || window.web3) {
11+
return;
12+
}
13+
if (navigator.userAgent.includes('Firefox')) {
914
// setup background connection
10-
const metamaskStream = new LocalMessageDuplexStream({
11-
name: 'inpage',
12-
target: 'contentscript'
15+
const metamaskStream = new WindowPostMessageStream({
16+
name: 'metamask-inpage',
17+
target: 'metamask-contentscript'
1318
});
1419

1520
// this will initialize the provider and set it as window.ethereum
16-
initProvider({
17-
connectionStream: metamaskStream
21+
initializeProvider({
22+
connectionStream: metamaskStream,
23+
shouldShimWeb3: true
1824
});
25+
} else if (navigator.userAgent.includes('iPhone')) {
26+
injectMobile();
1927
}
2028
})();

0 commit comments

Comments
 (0)