Skip to content

Commit

Permalink
[FEATURE] Create detectSnapLocation method to install a Local Snap (#…
Browse files Browse the repository at this point in the history
…5923)

* Local snaps install with controller version 0.26.2
  • Loading branch information
owencraston authored Mar 17, 2023
1 parent 437d572 commit 639b9bd
Show file tree
Hide file tree
Showing 17 changed files with 455 additions and 275 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v14
v16.13.0
8 changes: 4 additions & 4 deletions app/core/Engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { MetaMaskKeyring as QRHardwareKeyring } from '@keystonehq/metamask-airgapped-keyring';
import Encryptor from './Encryptor';
import { toChecksumAddress } from 'ethereumjs-util';
import RNFetchBlob from 'rn-fetch-blob';
import Networks, {
isMainnetByChainId,
getDecimalChainId,
Expand All @@ -63,6 +62,8 @@ import {
WebviewExecutionService,
buildSnapEndowmentSpecifications,
buildSnapRestrictedMethodSpecifications,
detectSnapLocation,
fetchFunction,
} from './Snaps';
import { getRpcMethodMiddleware } from './RPCMethods/RPCMethodMiddleware';
import {
Expand Down Expand Up @@ -451,14 +452,13 @@ class Engine {
checkSnapsBlockList(snapsToCheck, SNAP_BLOCKLIST),
state: initialState.snapController || {},
messenger: snapControllerMessenger,
fetchFunction: RNFetchBlob.config({ fileCache: true }).fetch.bind(
RNFetchBlob,
),
// TO DO
closeAllConnections: () =>
console.log(
'TO DO: Create method to close all connections (Closes all connections for the given origin, and removes the references)',
),
detectSnapLocation: (location, options) =>
detectSnapLocation(location, { ...options, fetch: fetchFunction }),
});

const controllers = [
Expand Down
3 changes: 3 additions & 0 deletions app/core/Snaps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ExcludedSnapPermissions,
ExcludedSnapEndowments,
} from './permissions';
import { detectSnapLocation, fetchFunction } from './location';

export {
snapsState,
Expand All @@ -22,4 +23,6 @@ export {
buildSnapRestrictedMethodSpecifications,
ExcludedSnapPermissions,
ExcludedSnapEndowments,
fetchFunction,
detectSnapLocation,
};
48 changes: 48 additions & 0 deletions app/core/Snaps/location/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* eslint-disable import/prefer-default-export */
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util';
import Logger from '../../../util/Logger';

const SNAPS_FETCH_LOG_TAG = 'Snaps/ fetch';

/**
* Reads and parses file from ReactNativeBlobUtil response
* @param path The path to the file to read and parse.
* @returns The parsed file data.
*/
const readAndParseFile = async (path: string) => {
try {
const data = await ReactNativeBlobUtil.fs.readFile(path, 'utf8');
return data;
} catch (error) {
Logger.log(SNAPS_FETCH_LOG_TAG, 'readAndParseFile error', error);
}
};

/**
* Converts a FetchBlobResponse object to a React Native Response object.
* @param response The FetchBlobResponse object to convert.
* @returns A new Response object with the same data as the input object.
*/
const convertFetchBlobResponseToResponse = async (
fetchBlobResponse: FetchBlobResponse,
): Promise<Response> => {
const headers = new Headers(fetchBlobResponse.respInfo.headers);
const status = fetchBlobResponse.respInfo.status;
const dataPath = fetchBlobResponse.data;
const data = await readAndParseFile(dataPath);
const response = new Response(data, { headers, status });
return response;
};

export const fetchFunction = async (
inputRequest: RequestInfo,
): Promise<Response> => {
const { config } = ReactNativeBlobUtil;
const urlToFetch: string =
typeof inputRequest === 'string' ? inputRequest : inputRequest.url;
const response: FetchBlobResponse = await config({ fileCache: true }).fetch(
'GET',
urlToFetch,
);
return await convertFetchBlobResponseToResponse(response);
};
112 changes: 112 additions & 0 deletions app/core/Snaps/location/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
SnapManifest,
VirtualFile,
NpmSnapFileNames,
createSnapManifest,
normalizeRelative,
} from '@metamask/snaps-utils';

import { SnapLocation } from './location';

export interface HttpOptions {
/**
* @default fetch
*/
fetch?: typeof fetch;
fetchOptions?: RequestInit;
}

export class HttpLocation implements SnapLocation {
// We keep contents separate because then we can use only one Blob in cache,
// which we convert to Uint8Array when actually returning the file.
//
// That avoids deepCloning file contents.
// I imagine ArrayBuffers are copy-on-write optimized, meaning
// in most often case we'll only have one file contents in common case.
private readonly cache = new Map<
string,
{ file: VirtualFile; contents: Blob | string }
>();

private validatedManifest?: VirtualFile<SnapManifest>;

private readonly url: URL;

private readonly fetchFn: typeof fetch;

private readonly fetchOptions?: RequestInit;

constructor(url: URL, opts: HttpOptions = {}) {
// TODO get the asserts working from @metamask/utils
// assertStruct(url.toString(), HttpSnapIdStruct, 'Invalid Snap Id: ');
this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
this.fetchOptions = opts.fetchOptions;
this.url = url;
}

async manifest(): Promise<VirtualFile<SnapManifest>> {
if (this.validatedManifest) {
return this.validatedManifest.clone();
}

// jest-fetch-mock doesn't handle new URL(), we need to convert .toString()
const canonicalPath = new URL(
NpmSnapFileNames.Manifest,
this.url.toString(),
).toString();
const contents = await (
await this.fetchFn(canonicalPath, this.fetchOptions)
).text();
const manifest = JSON.parse(contents);
const vfile = new VirtualFile<SnapManifest>({
value: contents,
result: createSnapManifest(manifest),
path: NpmSnapFileNames.Manifest,
data: { canonicalPath },
});
this.validatedManifest = vfile;

return this.manifest();
}

async fetch(path: string): Promise<VirtualFile> {
const relativePath = normalizeRelative(path);
const cached = this.cache.get(relativePath);
if (cached !== undefined) {
const { file, contents } = cached;
const value = contents.toString();
const vfile = file.clone();
vfile.value = value;
return vfile;
}

const canonicalPath = this.toCanonical(relativePath).toString();
const response = await this.fetchFn(canonicalPath, this.fetchOptions);
const vfile = new VirtualFile({
value: '',
path: relativePath,
data: { canonicalPath },
});

const blob = await response.text();

//TODO: get the asserts working from @metamask/utils
// assert(
// !this.cache.has(relativePath),
// 'Corrupted cache, multiple files with same path.',
// );
this.cache.set(relativePath, { file: vfile, contents: blob });

return this.fetch(relativePath);
}

get root(): URL {
return new URL(this.url.toString());
}

private toCanonical(path: string): URL {
// TODO get the asserts working from @metamask/utils
// assert(!path.startsWith('/'), 'Tried to parse absolute path.');
return new URL(path, this.url.toString());
}
}
2 changes: 2 additions & 0 deletions app/core/Snaps/location/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './location';
export * from './fetch';
57 changes: 57 additions & 0 deletions app/core/Snaps/location/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* eslint-disable import/prefer-default-export */
import {
SnapIdPrefixes,
SnapManifest,
VirtualFile,
} from '@metamask/snaps-utils';

import { HttpLocation, HttpOptions } from './http';
import { SnapLocation } from './location';

export class LocalLocation implements SnapLocation {
readonly #http: HttpLocation;

constructor(url: URL, opts: HttpOptions = {}) {
// TODO get the asserts working from @metamask/utils
// assertStruct(url.toString(), LocalSnapIdStruct, 'Invalid Snap Id');
// // TODO(ritave): Write deepMerge() which merges fetchOptions.
// assert(
// opts.fetchOptions === undefined,
// 'Currently adding fetch options to local: is unsupported.',
// );

this.#http = new HttpLocation(
new URL(url.toString().slice(SnapIdPrefixes.local.length)),
opts,
);
}

async manifest(): Promise<VirtualFile<SnapManifest>> {
const vfile = await this.#http.manifest();

return convertCanonical(vfile);
}

async fetch(path: string): Promise<VirtualFile> {
return convertCanonical(await this.#http.fetch(path));
}

get shouldAlwaysReload() {
return true;
}
}

/**
* Converts vfiles with canonical `http:` paths into `local:` paths.
*
* @param vfile - The {@link VirtualFile} to convert.
* @returns The same object with updated `.data.canonicalPath`.
*/
function convertCanonical<Result>(
vfile: VirtualFile<Result>,
): VirtualFile<Result> {
//TODO get the asserts working from @metamask/utils
// assert(vfile.data.canonicalPath !== undefined);
vfile.data.canonicalPath = `local:${vfile.data.canonicalPath}`;
return vfile;
}
64 changes: 64 additions & 0 deletions app/core/Snaps/location/location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { SemVerRange, SnapManifest, VirtualFile } from '@metamask/snaps-utils';
import { LocalLocation } from './local';

export interface NpmOptions {
/**
* @default DEFAULT_REQUESTED_SNAP_VERSION
*/
versionRange?: SemVerRange;
/**
* Whether to allow custom NPM registries outside of {@link DEFAULT_NPM_REGISTRY}.
*
* @default false
*/
allowCustomRegistries?: boolean;
}

type DetectSnapLocationOptions = NpmOptions & {
/**
* The function used to fetch data.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* @default false
*/
allowHttp?: boolean;
};

/**
* This should be exported from the @metamask/snaps-contracts package
* for now we will define it ourselves
*/
export interface SnapLocation {
/**
* All files are relative to the manifest, except the manifest itself.
*/
manifest(): Promise<VirtualFile<SnapManifest>>;
fetch(path: string): Promise<VirtualFile>;

readonly shouldAlwaysReload?: boolean;
}

/**
* Auto-magically detects which SnapLocation object to create based on the provided {@link location}.
*
* @param location - A {@link https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-8.md SIP-8} uri.
* @param opts - NPM options and feature flags.
* @returns SnapLocation based on url.
*/
export function detectSnapLocation(
location: string | URL,
opts?: DetectSnapLocationOptions,
): SnapLocation {
const root = new URL(location.toString());
switch (root.protocol) {
case 'local:':
return new LocalLocation(root, opts);
default:
throw new TypeError(
`Unrecognized "${root.protocol}" snap location protocol.`,
);
}
}
5 changes: 3 additions & 2 deletions app/util/browser/downloadFile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Linking } from 'react-native';
import Share, { ShareOptions } from 'react-native-share';
import { ShareOpenResult } from 'react-native-share/lib/typescript/types';
import RNFetchBlob, { FetchBlobResponse } from 'rn-fetch-blob';
// import RNFetchBlob, { FetchBlobResponse } from 'rn-fetch-blob';
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util';
import { strings } from '../../../locales/i18n';
import Device from '../device';

Expand Down Expand Up @@ -54,7 +55,7 @@ const checkAppleWalletPass = async (
};

const downloadFile = async (downloadUrl: string): Promise<DownloadResult> => {
const { config } = RNFetchBlob;
const { config } = ReactNativeBlobUtil;
const response: FetchBlobResponse = await config({ fileCache: true }).fetch(
'GET',
downloadUrl,
Expand Down
Loading

0 comments on commit 639b9bd

Please sign in to comment.