Polyfills for native crypto
parent
b2dd8d4f44
commit
77b938845a
|
@ -24,6 +24,8 @@ Uses:
|
|||
- Web: `yarn web`
|
||||
- Tips
|
||||
- `npx react-native info` Checks what has been installed.
|
||||
- On M1 macs, you need to exclude "arm64" from the target architectures
|
||||
- Annoyingly this must be re-set via XCode after every pod install
|
||||
|
||||
## Various notes
|
||||
|
||||
|
@ -31,3 +33,8 @@ Uses:
|
|||
- Suggests we might want to use `ASWebAuthenticationSession` on iOS
|
||||
- [react-native-inappbrowser-reborn](https://www.npmjs.com/package/react-native-inappbrowser-reborn) with `openAuth: true` might be worth exploring
|
||||
- We might even [get rejected by the app store](https://community.auth0.com/t/react-native-ios-app-rejected-on-appstore-for-using-react-native-auth0/36793) if we don't
|
||||
- Cryptography
|
||||
- We rely on [isomorphic-webcrypto](https://github.com/kevlened/isomorphic-webcrypto)
|
||||
- For the CRNG this uses [react-native-securerandom](https://github.com/robhogan/react-native-securerandom) which provides proper random on mobile
|
||||
- For the crypto this uses [msrcrypto](https://github.com/kevlened/msrCrypto) - but we should consider switching to [the MS maintained version](https://github.com/microsoft/MSR-JavaScript-Crypto)
|
||||
- In the future it might be preferable to move off of msrcrypto and use iOS and Android native modules, but nothing is available right now
|
|
@ -1,3 +1,18 @@
|
|||
module.exports = {
|
||||
presets: ['module:metro-react-native-babel-preset'],
|
||||
plugins: [
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
{
|
||||
// envName: 'APP_ENV',
|
||||
moduleName: '@env',
|
||||
path: '.env',
|
||||
blocklist: null,
|
||||
allowlist: null,
|
||||
safe: false,
|
||||
allowUndefined: true,
|
||||
verbose: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
|
|
|
@ -296,6 +296,8 @@ PODS:
|
|||
- RNScreens (3.13.1):
|
||||
- React-Core
|
||||
- React-RCTImage
|
||||
- RNSecureRandom (1.0.0):
|
||||
- React
|
||||
- Yoga (1.14.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
|
@ -335,6 +337,7 @@ DEPENDENCIES:
|
|||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
|
||||
- RNScreens (from `../node_modules/react-native-screens`)
|
||||
- RNSecureRandom (from `../node_modules/react-native-securerandom`)
|
||||
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||
|
||||
SPEC REPOS:
|
||||
|
@ -410,6 +413,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/react-native-inappbrowser-reborn"
|
||||
RNScreens:
|
||||
:path: "../node_modules/react-native-screens"
|
||||
RNSecureRandom:
|
||||
:path: "../node_modules/react-native-securerandom"
|
||||
Yoga:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
|
@ -449,6 +454,7 @@ SPEC CHECKSUMS:
|
|||
RNCAsyncStorage: 466b9df1a14bccda91da86e0b7d9a345d78e1673
|
||||
RNInAppBrowser: 3ff3a3b8f458aaf25aaee879d057352862edf357
|
||||
RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19
|
||||
RNSecureRandom: 0dcee021fdb3d50cd5cee5db0ebf583c42f5af0e
|
||||
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
|
||||
|
||||
PODFILE CHECKSUM: cf94853ebcb0d8e0d027dca9ab7a4ede886a8f20
|
||||
|
|
|
@ -11,9 +11,11 @@ console.log(metroResolver)
|
|||
module.exports = {
|
||||
resolver: {
|
||||
resolveRequest: (context, moduleName, platform) => {
|
||||
// HACK
|
||||
// metro doesn't support the "exports" directive in package.json
|
||||
// so we have to manually fix some imports
|
||||
// see https://github.com/facebook/metro/issues/670
|
||||
// -prf
|
||||
if (moduleName.startsWith('ucans')) {
|
||||
const subpath = moduleName.split('/').slice(1)
|
||||
if (subpath.length === 0) {
|
||||
|
@ -34,14 +36,19 @@ module.exports = {
|
|||
filePath,
|
||||
}
|
||||
}
|
||||
// HACK
|
||||
// this module has the same problem with the "exports" module
|
||||
// but also we need modules to use our version of webcrypto
|
||||
// so here we're routing to a module we define
|
||||
// -prf
|
||||
if (moduleName === 'one-webcrypto') {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: path.join(
|
||||
context.projectRoot,
|
||||
'node_modules',
|
||||
'one-webcrypto',
|
||||
'browser.mjs',
|
||||
'src',
|
||||
'platform',
|
||||
'polyfills.native.ts',
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,15 +20,18 @@
|
|||
"@react-navigation/native": "^6.0.10",
|
||||
"@react-navigation/native-stack": "^6.6.2",
|
||||
"@react-navigation/stack": "^6.2.1",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"mobx": "^6.6.0",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-state-tree": "^5.1.5",
|
||||
"msrcrypto": "^1.5.8",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-native": "0.68.2",
|
||||
"react-native-inappbrowser-reborn": "^3.6.3",
|
||||
"react-native-safe-area-context": "^4.3.1",
|
||||
"react-native-screens": "^3.13.1",
|
||||
"react-native-securerandom": "^1.0.0",
|
||||
"react-native-web": "^0.17.7",
|
||||
"ucans": "0.9.1"
|
||||
},
|
||||
|
@ -49,6 +52,7 @@
|
|||
"eslint": "^7.32.0",
|
||||
"jest": "^26.6.3",
|
||||
"metro-react-native-babel-preset": "^0.67.0",
|
||||
"react-native-dotenv": "^3.3.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"typescript": "^4.4.4"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, {useState, useEffect} from 'react'
|
||||
import {whenWebCrypto} from './platform/polyfills.native'
|
||||
import {RootStore, setupState, RootStoreProvider} from './state'
|
||||
import * as Routes from './routes'
|
||||
|
||||
|
@ -7,7 +8,7 @@ function App() {
|
|||
|
||||
// init
|
||||
useEffect(() => {
|
||||
setupState().then(setRootStore)
|
||||
whenWebCrypto.then(() => setupState()).then(setRootStore)
|
||||
}, [])
|
||||
|
||||
// show nothing prior to init
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
import {Linking} from 'react-native'
|
||||
import * as auth from '@adxp/auth'
|
||||
import {InAppBrowser} from 'react-native-inappbrowser-reborn'
|
||||
import {isWeb} from '../platform/detection'
|
||||
import {makeAppUrl} from '../platform/urls'
|
||||
import * as env from '../env'
|
||||
|
||||
const SCOPE = auth.writeCap(
|
||||
'did:key:z6MkfRiFMLzCxxnw6VMrHK8pPFt4QAHS3jX3XM87y9rta6kP',
|
||||
'did:example:microblog',
|
||||
)
|
||||
|
||||
export async function isAuthed(authStore: auth.BrowserStore) {
|
||||
return await authStore.hasUcan(SCOPE)
|
||||
}
|
||||
|
||||
export async function logout(authStore: auth.BrowserStore) {
|
||||
await authStore.reset()
|
||||
}
|
||||
|
||||
export async function parseUrlForUcan() {
|
||||
// @ts-ignore window is defined -prf
|
||||
const fragment = window.location.hash
|
||||
if (fragment.length < 1) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const ucan = await auth.parseLobbyResponseHashFragment(fragment)
|
||||
// @ts-ignore window is defined -prf
|
||||
window.location.hash = ''
|
||||
return ucan
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestAppUcan(authStore: auth.BrowserStore) {
|
||||
const did = await authStore.getDid()
|
||||
const returnUrl = makeAppUrl()
|
||||
const fragment = auth.requestAppUcanHashFragment(did, SCOPE, returnUrl)
|
||||
const url = `${env.AUTH_LOBBY}#${fragment}`
|
||||
|
||||
if (isWeb) {
|
||||
// @ts-ignore window is defined -prf
|
||||
window.location.href = url
|
||||
return false
|
||||
}
|
||||
|
||||
if (await InAppBrowser.isAvailable()) {
|
||||
const res = await InAppBrowser.openAuth(url, returnUrl, {
|
||||
// iOS Properties
|
||||
ephemeralWebSession: false,
|
||||
// Android Properties
|
||||
showTitle: false,
|
||||
enableUrlBarHiding: true,
|
||||
enableDefaultShare: false,
|
||||
})
|
||||
if (res.type === 'success' && res.url) {
|
||||
Linking.openURL(res.url)
|
||||
} else {
|
||||
console.error('Bad response', res)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
Linking.openURL(url)
|
||||
}
|
||||
return true
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
if (typeof process.env.REACT_APP_AUTH_LOBBY !== 'string') {
|
||||
import {REACT_APP_AUTH_LOBBY} from '@env'
|
||||
|
||||
if (typeof REACT_APP_AUTH_LOBBY !== 'string') {
|
||||
throw new Error('ENV: No auth lobby provided')
|
||||
}
|
||||
|
||||
export const AUTH_LOBBY = process.env.REACT_APP_AUTH_LOBBY
|
||||
export const AUTH_LOBBY = REACT_APP_AUTH_LOBBY
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import {generateSecureRandom} from 'react-native-securerandom'
|
||||
import crypto from 'msrcrypto'
|
||||
import '@zxing/text-encoding' // TextEncoder / TextDecoder
|
||||
|
||||
export const whenWebCrypto = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const bytes = await generateSecureRandom(48)
|
||||
crypto.initPrng(Array.from(bytes))
|
||||
|
||||
// @ts-ignore global.window exists -prf
|
||||
if (!global.window.crypto) {
|
||||
// @ts-ignore global.window exists -prf
|
||||
global.window.crypto = crypto
|
||||
}
|
||||
resolve(true)
|
||||
} catch (e: any) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
export const webcrypto = crypto
|
|
@ -0,0 +1 @@
|
|||
// do nothing
|
|
@ -0,0 +1,142 @@
|
|||
import {Linking} from 'react-native'
|
||||
import * as auth from '@adxp/auth'
|
||||
import * as ucan from 'ucans'
|
||||
import {InAppBrowser} from 'react-native-inappbrowser-reborn'
|
||||
import {isWeb} from '../platform/detection'
|
||||
import {makeAppUrl} from '../platform/urls'
|
||||
import * as storage from './storage'
|
||||
import * as env from '../env'
|
||||
|
||||
const SCOPE = auth.writeCap(
|
||||
'did:key:z6MkfRiFMLzCxxnw6VMrHK8pPFt4QAHS3jX3XM87y9rta6kP',
|
||||
'did:example:microblog',
|
||||
)
|
||||
|
||||
export async function isAuthed(authStore: ReactNativeStore) {
|
||||
return await authStore.hasUcan(SCOPE)
|
||||
}
|
||||
|
||||
export async function logout(authStore: ReactNativeStore) {
|
||||
await authStore.reset()
|
||||
}
|
||||
|
||||
export async function parseUrlForUcan() {
|
||||
if (isWeb) {
|
||||
// @ts-ignore window is defined -prf
|
||||
const fragment = window.location.hash
|
||||
if (fragment.length < 1) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const ucan = await auth.parseLobbyResponseHashFragment(fragment)
|
||||
// @ts-ignore window is defined -prf
|
||||
window.location.hash = ''
|
||||
return ucan
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestAppUcan(authStore: ReactNativeStore) {
|
||||
const did = await authStore.getDid()
|
||||
const returnUrl = makeAppUrl()
|
||||
const fragment = auth.requestAppUcanHashFragment(did, SCOPE, returnUrl)
|
||||
const url = `${env.AUTH_LOBBY}#${fragment}`
|
||||
|
||||
if (isWeb) {
|
||||
// @ts-ignore window is defined -prf
|
||||
window.location.href = url
|
||||
return false
|
||||
}
|
||||
|
||||
if (await InAppBrowser.isAvailable()) {
|
||||
const res = await InAppBrowser.openAuth(url, returnUrl, {
|
||||
// iOS Properties
|
||||
ephemeralWebSession: false,
|
||||
// Android Properties
|
||||
showTitle: false,
|
||||
enableUrlBarHiding: true,
|
||||
enableDefaultShare: false,
|
||||
})
|
||||
if (res.type === 'success' && res.url) {
|
||||
Linking.openURL(res.url)
|
||||
} else {
|
||||
console.error('Bad response', res)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
Linking.openURL(url)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export class ReactNativeStore extends auth.AuthStore {
|
||||
private keypair: ucan.EdKeypair
|
||||
private ucanStore: ucan.Store
|
||||
|
||||
constructor(keypair: ucan.EdKeypair, ucanStore: ucan.Store) {
|
||||
super()
|
||||
this.keypair = keypair
|
||||
this.ucanStore = ucanStore
|
||||
}
|
||||
|
||||
static async load(): Promise<ReactNativeStore> {
|
||||
const keypair = await ReactNativeStore.loadOrCreateKeypair()
|
||||
|
||||
const storedUcans = await ReactNativeStore.getStoredUcanStrs()
|
||||
const ucanStore = await ucan.Store.fromTokens(storedUcans)
|
||||
|
||||
return new ReactNativeStore(keypair, ucanStore)
|
||||
}
|
||||
|
||||
static async loadOrCreateKeypair(): Promise<ucan.EdKeypair> {
|
||||
const storedKey = await storage.loadString('adxKey')
|
||||
if (storedKey) {
|
||||
return ucan.EdKeypair.fromSecretKey(storedKey)
|
||||
} else {
|
||||
// @TODO: again just stand in since no actual root keys
|
||||
const keypair = await ucan.EdKeypair.create({exportable: true})
|
||||
storage.saveString('adxKey', await keypair.export())
|
||||
return keypair
|
||||
}
|
||||
}
|
||||
|
||||
static async getStoredUcanStrs(): Promise<string[]> {
|
||||
const storedStr = await storage.loadString('adxUcans')
|
||||
if (!storedStr) {
|
||||
return []
|
||||
}
|
||||
return storedStr.split(',')
|
||||
}
|
||||
|
||||
static setStoredUcanStrs(ucans: string[]): void {
|
||||
storage.saveString('adxUcans', ucans.join(','))
|
||||
}
|
||||
|
||||
protected async getKeypair(): Promise<ucan.EdKeypair> {
|
||||
return this.keypair
|
||||
}
|
||||
|
||||
async addUcan(token: ucan.Chained): Promise<void> {
|
||||
this.ucanStore.add(token)
|
||||
const storedUcans = await ReactNativeStore.getStoredUcanStrs()
|
||||
ReactNativeStore.setStoredUcanStrs([...storedUcans, token.encoded()])
|
||||
}
|
||||
|
||||
async getUcanStore(): Promise<ucan.Store> {
|
||||
return this.ucanStore
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
storage.clear()
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
this.clear()
|
||||
this.keypair = await ReactNativeStore.loadOrCreateKeypair()
|
||||
this.ucanStore = await ucan.Store.fromTokens([])
|
||||
}
|
||||
}
|
|
@ -4,17 +4,17 @@
|
|||
*/
|
||||
|
||||
import {getEnv, IStateTreeNode} from 'mobx-state-tree'
|
||||
import * as auth from '@adxp/auth'
|
||||
import {ReactNativeStore} from './auth'
|
||||
import {API} from '../api'
|
||||
|
||||
export class Environment {
|
||||
api = new API()
|
||||
authStore?: auth.BrowserStore
|
||||
authStore?: ReactNativeStore
|
||||
|
||||
constructor() {}
|
||||
|
||||
async setup() {
|
||||
this.authStore = await auth.BrowserStore.load()
|
||||
this.authStore = await ReactNativeStore.load()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
} from './models/root-store'
|
||||
import {Environment} from './env'
|
||||
import * as storage from './storage'
|
||||
import * as auth from '../api/auth'
|
||||
import * as auth from './auth'
|
||||
|
||||
const ROOT_STATE_STORAGE_KEY = 'root'
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
|
||||
// import {UserConfig} from '../../api'
|
||||
import * as auth from '../../api/auth'
|
||||
import * as auth from '../auth'
|
||||
import {withEnvironment} from '../env'
|
||||
|
||||
export const SessionModel = types
|
||||
|
|
26
yarn.lock
26
yarn.lock
|
@ -3067,6 +3067,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
|
||||
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
|
||||
|
||||
"@zxing/text-encoding@^0.9.0":
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
|
||||
integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==
|
||||
|
||||
abab@^2.0.3, abab@^2.0.5:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
|
||||
|
@ -3833,7 +3838,7 @@ balanced-match@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
base64-js@^1.1.2, base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
base64-js@*, base64-js@^1.1.2, base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
@ -9411,6 +9416,11 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3:
|
|||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
msrcrypto@^1.5.8:
|
||||
version "1.5.8"
|
||||
resolved "https://registry.yarnpkg.com/msrcrypto/-/msrcrypto-1.5.8.tgz#be419be4945bf134d8af52e9d43be7fa261f4a1c"
|
||||
integrity sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q==
|
||||
|
||||
multicast-dns@^7.2.5:
|
||||
version "7.2.5"
|
||||
resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced"
|
||||
|
@ -11016,6 +11026,13 @@ react-native-codegen@^0.0.17:
|
|||
jscodeshift "^0.13.1"
|
||||
nullthrows "^1.1.1"
|
||||
|
||||
react-native-dotenv@^3.3.1:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.3.1.tgz#8f399cf28ca77d860d8e7f7323e439fa60a8ca0b"
|
||||
integrity sha512-gAKXout1XCwCqJ3QPGoQAF2eRzOHgOnwg3x19z+ssow8bDIksJeKBqvoHDyGziVilAIP1J0bEC9Jf+VF8nFang==
|
||||
dependencies:
|
||||
dotenv "^10.0.0"
|
||||
|
||||
react-native-gradle-plugin@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/react-native-gradle-plugin/-/react-native-gradle-plugin-0.0.6.tgz#b61a9234ad2f61430937911003cddd7e15c72b45"
|
||||
|
@ -11042,6 +11059,13 @@ react-native-screens@^3.13.1:
|
|||
react-freeze "^1.0.0"
|
||||
warn-once "^0.1.0"
|
||||
|
||||
react-native-securerandom@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-securerandom/-/react-native-securerandom-1.0.0.tgz#1cff2f727c90c9ec3318b42dbf825a628b53b49b"
|
||||
integrity sha512-lnhcsWloFzMN/HffyDBlh4VlqdhDH8uxEzUIH3aJPgC1PxV6OKZkvAk409EwsAhcmG/z3yZuVKegKpUr5IM9ug==
|
||||
dependencies:
|
||||
base64-js "*"
|
||||
|
||||
react-native-web@^0.17.7:
|
||||
version "0.17.7"
|
||||
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.17.7.tgz#038899dbc94467a0ca0be214b88a30e0c117b176"
|
||||
|
|
Loading…
Reference in New Issue